learning-go

Learn Go by building a REST API and a Command Line Interface (CLI)

Add finance/currency-converter to the API

🏋️‍♀️ CHALLENGE: try to implement this by yourself before proceeding.

We are going to add support to execute a currency conversion when a GET request is sent to /finance/currconv.

Go to the API folder.

The first change is to update the Library version and run go mod tidy:

module github.com/renato0307/learning-go-api

go 1.17

require github.com/gin-gonic/gin v1.7.7

require (
	github.com/renato0307/learning-go-lib v0.0.4 // change
	github.com/stretchr/testify v1.7.0
)

// ...continues

We need to create finance folder and a few new files to support the currency converter:

mkdir finance
touch finance/finance.go
touch finance/finance_test.go

The first change is to add a SetRouterGroup in the finance/finance.go file so we can define routes for the finance category:

package finance

import (
	"github.com/gin-gonic/gin"
	"github.com/renato0307/learning-go-lib/finance"
)

// SetRouterGroup defines all the routes for the finance functions
func SetRouterGroup(f finance.Interface, base *gin.RouterGroup) *gin.RouterGroup {
	financeGroup := base.Group("/finance")
	{
		financeGroup.GET("/currconv", getCurrConv(f))
		// Add here more functions in the finance category
	}

	return financeGroup
}

func getCurrConv(f finance.Interface) gin.HandlerFunc {
	return func(c *gin.Context) {
		// TODO
	}
}

For this to work we also need call the SetRouterGroup in main.go:

package main

import (
	"fmt"
	"os"

	"github.com/gin-gonic/gin"
	"github.com/renato0307/learning-go-api/finance" // new
	"github.com/renato0307/learning-go-api/programming"
	financelib "github.com/renato0307/learning-go-lib/finance" // new
	programminglib "github.com/renato0307/learning-go-lib/programming"
)

func main() {
	r := gin.Default()
	r.GET("/", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "Hello, welcome to the learning-go-api",
		})
	})

	base := r.Group("/v1")

	p := programminglib.ProgrammingFunctions{}
	programming.SetRouterGroup(&p, base)

	useDefaultUrl := ""                                        // new
	apiKey := getRequiredEnv("CURRCONV_API_KEY")               // new
	f := financelib.NewFinanceFunctions(useDefaultUrl, apiKey) // new
	finance.SetRouterGroup(&f, base)                           // new

	r.Run()
}

func getRequiredEnv(key string) string { // new
	value, exists := os.LookupEnv(key)

	if !exists {
		panic(fmt.Sprintf("error: %s environment variable was not defined", key))
	}

	return value
}

For us to be able to call the finance functions we need to have an API key. This will be a configuration of the API, which will be passed by the use of an environment variable. Some of the extra code (like the getRequiredEnv function) serves that purpose.

With this change, before starting the API we need to define a new environment variable.

For example:

CURRCONV_API_KEY=api_key_value_here go run main.go

🕵️‍♀️ GO-EXTRA: Panic

We have used the panic built-in function to raise an error and interrupt the execution of the code if a required environment variable is missing.

For more information about panic and recover check this blog post.


Now let's implement the getCurrConv function in the finance/finance.go file:

// getCurrConv handles the currency conversion request.
//
// The request requires the from, to and amount parameters in the query string.
// It returns HTTP 200 on success.
// Returns HTTP 400 if there is a missing parameter.
// Returns HTTP 500 if there is another error.
func getCurrConv(f finance.Interface) gin.HandlerFunc {
	return func(c *gin.Context) {
		from := c.Query("from")
		to := c.Query("to")
		amount := c.Query("amount")

		if from == "" {
			c.JSON(http.StatusBadRequest, "error: 'from' parameter is required")
			return
		}

		if to == "" {
			c.JSON(http.StatusBadRequest, "error: 'to' parameter is required")
			return
		}

		if amount == "" {
			c.JSON(http.StatusBadRequest, "error: 'amount' parameter is required")
			return
		}

		amountFloat, err := strconv.ParseFloat(amount, 64)
		if err != nil {
			c.JSON(http.StatusBadRequest, "error: 'amount' is not a valid number")
			return
		}

		convertAmount, err := f.ConvertCurrency(from, to, amountFloat)
		if err != nil {
			err = fmt.Errorf("error converting the currency: %s", err.Error())
			c.JSON(http.StatusInternalServerError, err.Error())
		}

		output := currConvOutput{
			From:            from,
			To:              to,
			Amount:          amountFloat,
			ConvertedAmount: convertAmount,
		}

		c.JSON(http.StatusOK, output)
	}
}

The code is pretty explanatory so let's proceed to the tests.

The unit tests for main.go

As we added some logic to the main.go file, let's add some tests:

touch main_test.go

The contents of the test are:

package main

import (
	"os"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestGetRequiredEnv(t *testing.T) {
	// arrange
	varName := "ENV_VAR_NAME"
	varValue := "ENV_VAR_VALUE"

	os.Setenv(varName, varValue)

	// act
	value := getRequiredEnv(varName)

	// assert
	assert.Equal(t, varValue, value)
}

func TestGetRequiredEnvWithMissingEnvironment(t *testing.T) {
	// act & assert
	assert.Panics(t, func() {
		getRequiredEnv("ENV_VAR_NAME")
	})
}

I want to highlight the usage of the assert.Panics function to check if the getRequiredEnv panics when an environment variable is missing.

The unit tests for finance/finance.go

🏋️‍♀️ CHALLENGE: try to implement this by yourself before proceeding.

The test for the success scenario is:

func TestGetCurrConv(t *testing.T) {
	// arrange
	from := "EUR"
	to := "USD"
	amount := 10.0
	amountConverted := 11.0

	mockInterface := financelib.MockInterface{}
	mockCall := mockInterface.On("ConvertCurrency", from, to, amount)
	mockCall.Return(amountConverted, nil)

	r := setupGin(&mockInterface)
	w := httptest.NewRecorder()

	url := fmt.Sprintf("/v1/finance/currconv?from=%s&to=%s&amount=%f", from, to, amount)
	req, _ := http.NewRequest("GET", url, nil)

	// act
	r.ServeHTTP(w, req)

	// assert
	assert.Equal(t, w.Code, http.StatusOK)

	output := getCurrConvOutput{}
	err := json.Unmarshal(w.Body.Bytes(), &output)

	assert.Nil(t, err)
	assert.Equal(t, from, output.From)
	assert.Equal(t, to, output.To)
	assert.Equal(t, amount, output.Amount)
	assert.Equal(t, amountConverted, output.ConvertedAmount)

	mockInterface.AssertExpectations(t)
}

The full tests, including the error scenarios are:

package finance

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	financelib "github.com/renato0307/learning-go-lib/finance"
	"github.com/stretchr/testify/assert"
)

func setupGin(mockInterface *financelib.MockInterface) *gin.Engine {
	r := gin.Default()
	v1 := r.Group("/v1")
	SetRouterGroup(mockInterface, v1)

	return r
}

func TestGetCurrConv(t *testing.T) {
	// arrange
	from := "EUR"
	to := "USD"
	amount := 10.0
	amountConverted := 11.0

	mockInterface := financelib.MockInterface{}
	mockCall := mockInterface.On("ConvertCurrency", from, to, amount)
	mockCall.Return(amountConverted, nil)

	r := setupGin(&mockInterface)
	w := httptest.NewRecorder()

	url := fmt.Sprintf("/v1/finance/currconv?from=%s&to=%s&amount=%f", from, to, amount)
	req, _ := http.NewRequest("GET", url, nil)

	// act
	r.ServeHTTP(w, req)

	// assert
	assert.Equal(t, w.Code, http.StatusOK)

	output := getCurrConvOutput{}
	err := json.Unmarshal(w.Body.Bytes(), &output)

	assert.Nil(t, err)
	assert.Equal(t, from, output.From)
	assert.Equal(t, to, output.To)
	assert.Equal(t, amount, output.Amount)
	assert.Equal(t, amountConverted, output.ConvertedAmount)

	mockInterface.AssertExpectations(t)
}

func TestGetCurrConvWithMissingFrom(t *testing.T) {
	// arrange
	to := "USD"
	amount := 10.0

	mockInterface := financelib.MockInterface{}
	r := setupGin(&mockInterface)
	w := httptest.NewRecorder()

	url := fmt.Sprintf("/v1/finance/currconv?to=%s&amount=%f", to, amount)
	req, _ := http.NewRequest("GET", url, nil)

	// act
	r.ServeHTTP(w, req)

	// assert
	assert.Equal(t, w.Code, http.StatusBadRequest)
}

func TestGetCurrConvWithMissingTo(t *testing.T) {
	// arrange
	from := "EUR"
	amount := 10.0

	mockInterface := financelib.MockInterface{}
	r := setupGin(&mockInterface)
	w := httptest.NewRecorder()

	url := fmt.Sprintf("/v1/finance/currconv?from=%s&amount=%f", from, amount)
	req, _ := http.NewRequest("GET", url, nil)

	// act
	r.ServeHTTP(w, req)

	// assert
	assert.Equal(t, w.Code, http.StatusBadRequest)
}

func TestGetCurrConvWithMissingAmount(t *testing.T) {
	// arrange
	from := "EUR"
	to := "USD"

	mockInterface := financelib.MockInterface{}
	r := setupGin(&mockInterface)
	w := httptest.NewRecorder()

	url := fmt.Sprintf("/v1/finance/currconv?from=%s&to=%s", from, to)
	req, _ := http.NewRequest("GET", url, nil)

	// act
	r.ServeHTTP(w, req)

	// assert
	assert.Equal(t, w.Code, http.StatusBadRequest)
}

func TestGetCurrConvWithInvalidAmount(t *testing.T) {
	// arrange
	from := "EUR"
	to := "USD"
	amount := "invalid"

	mockInterface := financelib.MockInterface{}
	r := setupGin(&mockInterface)
	w := httptest.NewRecorder()

	url := fmt.Sprintf("/v1/finance/currconv?from=%s&to=%s&amount=%s", from, to, amount)
	req, _ := http.NewRequest("GET", url, nil)

	// act
	r.ServeHTTP(w, req)

	// assert
	assert.Equal(t, w.Code, http.StatusBadRequest)
}

func TestGetCurrConvWithLibraryError(t *testing.T) {
	// arrange
	from := "EUR"
	to := "USD"
	amount := 10.0

	mockInterface := financelib.MockInterface{}
	mockCall := mockInterface.On("ConvertCurrency", from, to, amount)
	mockCall.Return(0.0, errors.New("fake error"))

	r := setupGin(&mockInterface)
	w := httptest.NewRecorder()

	url := fmt.Sprintf("/v1/finance/currconv?from=%s&to=%s&amount=%f", from, to, amount)
	req, _ := http.NewRequest("GET", url, nil)

	// act
	r.ServeHTTP(w, req)

	// assert
	assert.Equal(t, w.Code, http.StatusInternalServerError)
	mockInterface.AssertExpectations(t)
}

Manual tests

To run manual tests first start the API using the correct API key created before:

CURRCONV_API_KEY=<PUT-HERE-THE-API-KEY> go run main.go

Next, in a new terminal window, use httpie to do a couple of calls.

The success call is:

http "localhost:8080/v1/finance/currconv?from=EUR&to=USD&amount=200"

The result should be similar to:

HTTP/1.1 200 OK
Content-Length: 76
Content-Type: application/json; charset=utf-8
Date: Tue, 28 Dec 2021 06:47:24 GMT

{
    "amount": 200,
    "converted_amount": 226.42600000000002,
    "from": "EUR",
    "to": "USD"
}

Wrapping up the API

Commit and push everything. Create a new tag.

git add .
git commit -m "feat: add finance/currconv"
git push
git tag -a v0.0.3 -m "v0.0.3"
git push origin v0.0.3

Next

The next section is Create a local kubernetes cluster.