Create a money exchange app
One of the primary purposes of Gosoline is to help you build an HTTP server. An HTTP server, in the context of Gosoline, is a module that:
- Runs indefinitely
- Listens to a port for requests
- Provides responses to those requests
Our httpserver
package provides a convenient way to create HTTP servers.
In this tutorial, you'll create a money exchange web service. This service will have two endpoints:
GET /euro/{AMOUNT IN SOURCE CURRENCY}/{SOURCE CURRENCY}
GET /euro-at-date/{AMOUNT IN SOURCE CURRENCY}/{SOURCE CURRENCY}/{EXCHANGE RATE DATE}
Both of these endpoints:
- Accept an amount in a source currency.
- Convert that amount to euros based on an exchange rate.
The euro-at-date
endpoint allows you to specify a historical date for the exchange rate.
Before you begin
Before you begin, make sure you have Golang installed on your machine.
Set up your file structure
First, you need to set up the following file structure:
money-exchange/
├── handler.go
├── definer.go
├── config.dist.yml
└── main.go
For example, in Unix, run:
mkdir money-exchange; cd money-exchange
touch handler.go
touch definer.go
touch config.dist.yml
touch main.go
Those are all the files you need to build your web service with gosoline! Next, you'll implement each of these files, starting with handler.go
.
Implement handler.go
In handler.go
, add the following code:
handler.go
package main
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
"github.com/justtrackio/gosoline/pkg/cfg"
"github.com/justtrackio/gosoline/pkg/currency"
"github.com/justtrackio/gosoline/pkg/httpserver"
"github.com/justtrackio/gosoline/pkg/log"
)
type euroHandler struct {
logger log.Logger
currencyService currency.Service
}
func NewEuroHandler(ctx context.Context, config cfg.Config, logger log.Logger) (*euroHandler, error) {
// Instantiate a new currencyService
currencyService, err := currency.New(ctx, config, logger)
if err != nil {
return nil, fmt.Errorf("can not create currencyService: %w", err)
}
// Return a euroHandler
return &euroHandler{
logger: logger,
currencyService: currencyService,
}, nil
}
func (h *euroHandler) Handle(requestContext context.Context, request *httpserver.Request) (response *httpserver.Response, err error) {
// Get a currency and amountString from the request parameters.
currency := request.Params.ByName("currency")
amountString := request.Params.ByName("amount")
// Parse a float value from amountString.
amount, err := strconv.ParseFloat(amountString, 64)
// Send a 400 Bad Request response if amountString can't be parsed into a valid float.
if err != nil {
h.logger.Error("cannot parse amount %s: %w", amountString, err)
return httpserver.NewStatusResponse(http.StatusBadRequest), nil
}
// Convert the amount from the source currency to euros.
result, err := h.currencyService.ToEur(requestContext, amount, currency)
// Send a 500 Internal Server Error if the server can't convert the amount.
if err != nil {
h.logger.Error("cannot convert amount %f with currency %s: %w", amount, currency, err)
return httpserver.NewStatusResponse(http.StatusInternalServerError), nil
}
// Send a 200 OK Json response back to the client with the results.
return httpserver.NewJsonResponse(result), nil
}
type euroAtDateHandler struct {
logger log.Logger
currencyService currency.Service
}
func NewEuroAtDateHandler(ctx context.Context, config cfg.Config, logger log.Logger) (*euroAtDateHandler, error) {
currencyService, err := currency.New(ctx, config, logger)
if err != nil {
return nil, fmt.Errorf("can not create currencyService: %w", err)
}
return &euroAtDateHandler{
logger: logger,
currencyService: currencyService,
}, nil
}
func (h *euroAtDateHandler) Handle(requestContext context.Context, request *httpserver.Request) (response *httpserver.Response, err error) {
// Get the request parameters and parse their string values.
currency := request.Params.ByName("currency")
dateString := request.Params.ByName("date")
date, err := time.Parse(time.RFC3339, dateString)
// Send a 500 Internal Server Error if the service can't parse the params or convert the currency.
if err != nil {
h.logger.Error("cannot parse date %s: %w", dateString, err)
return httpserver.NewStatusResponse(http.StatusInternalServerError), nil
}
amountString := request.Params.ByName("amount")
amount, err := strconv.ParseFloat(amountString, 64)
if err != nil {
h.logger.Error("cannot parse amount %s: %w", amountString, err)
return httpserver.NewStatusResponse(http.StatusInternalServerError), nil
}
result, err := h.currencyService.ToEurAtDate(requestContext, amount, currency, date)
if err != nil {
h.logger.Error("cannot convert amount %f with currency %s at date %v: %w", amount, currency, date, err)
return httpserver.NewStatusResponse(http.StatusInternalServerError), nil
}
// Send a 200 OK Json response back to the client with the results.
return httpserver.NewJsonResponse(result), nil
}
Now, you'll walkthrough this file in detail to learn how it works.
Import your dependencies
At the top of handler.go
, you declared the package and imported some dependencies:
package main
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
"github.com/justtrackio/gosoline/pkg/cfg"
"github.com/justtrackio/gosoline/pkg/currency"
"github.com/justtrackio/gosoline/pkg/httpserver"
"github.com/justtrackio/gosoline/pkg/log"
)
Here, you declared the package as main
. Then, you imported several standard modules along with four gosoline dependencies:
cfg
currency
httpserver
log
Define a euroHandler structure
Next, you created a euroHandler
struct:
type euroHandler struct {
logger log.Logger
currencyService currency.Service
}
You'll use this in a few places to carry data about your logger and currency service. You'll also implement its Handle()
method to handle HTTP requests.
Define a handler
Then, you implemented a function for creating new euroHandler
structs:
func NewEuroHandler(ctx context.Context, config cfg.Config, logger log.Logger) (*euroHandler, error) {
// Instantiate a new currencyService
currencyService, err := currency.New(ctx, config, logger)
if err != nil {
return nil, fmt.Errorf("can not create currencyService: %w", err)
}
// Return a euroHandler
return &euroHandler{
logger: logger,
currencyService: currencyService,
}, nil
}
You'll use this later to create a new euroHandler
.
Implement a request handler
Then, you implemented euroHandler.Handle()
for handling HTTP requests:
func (h *euroHandler) Handle(requestContext context.Context, request *httpserver.Request) (response *httpserver.Response, err error) {
// Get a currency and amountString from the request parameters.
currency := request.Params.ByName("currency")
amountString := request.Params.ByName("amount")
// Parse a float value from amountString.
amount, err := strconv.ParseFloat(amountString, 64)
// Send a 400 Bad Request response if amountString can't be parsed into a valid float.
if err != nil {
h.logger.Error("cannot parse amount %s: %w", amountString, err)
return httpserver.NewStatusResponse(http.StatusBadRequest), nil
}
// Convert the amount from the source currency to euros.
result, err := h.currencyService.ToEur(requestContext, amount, currency)
// Send a 500 Internal Server Error if the server can't convert the amount.
if err != nil {
h.logger.Error("cannot convert amount %f with currency %s: %w", amount, currency, err)
return httpserver.NewStatusResponse(http.StatusInternalServerError), nil
}
// Send a 200 OK Json response back to the client with the results.
return httpserver.NewJsonResponse(result), nil
}
Define a euroAtDateHandler
Like you did with euroHandler
, you defined a euroAtDateHandler
struct and a corresponding constructor:
type euroAtDateHandler struct {
logger log.Logger
currencyService currency.Service
}
func NewEuroAtDateHandler(ctx context.Context, config cfg.Config, logger log.Logger) (*euroAtDateHandler, error) {
currencyService, err := currency.New(ctx, config, logger)
if err != nil {
return nil, fmt.Errorf("can not create currencyService: %w", err)
}
return &euroAtDateHandler{
logger: logger,
currencyService: currencyService,
}, nil
}
The logic here is very similar to the logic for euroHandler
.
Implement a second request handler
Finally, you implemented euroAtDateHandler.Handle()
for handling HTTP requests:
func (h *euroAtDateHandler) Handle(requestContext context.Context, request *httpserver.Request) (response *httpserver.Response, err error) {
// Get the request parameters and parse their string values.
currency := request.Params.ByName("currency")
dateString := request.Params.ByName("date")
date, err := time.Parse(time.RFC3339, dateString)
// Send a 500 Internal Server Error if the service can't parse the params or convert the currency.
if err != nil {
h.logger.Error("cannot parse date %s: %w", dateString, err)
return httpserver.NewStatusResponse(http.StatusInternalServerError), nil
}
amountString := request.Params.ByName("amount")
amount, err := strconv.ParseFloat(amountString, 64)
if err != nil {
h.logger.Error("cannot parse amount %s: %w", amountString, err)
return httpserver.NewStatusResponse(http.StatusInternalServerError), nil
}
result, err := h.currencyService.ToEurAtDate(requestContext, amount, currency, date)
if err != nil {
h.logger.Error("cannot convert amount %f with currency %s at date %v: %w", amount, currency, date, err)
return httpserver.NewStatusResponse(http.StatusInternalServerError), nil
}
// Send a 200 OK Json response back to the client with the results.
return httpserver.NewJsonResponse(result), nil
}
The logic here is very similar to the logic for euroHandler.Handle()
.
Now, you've fully implemented your request handlers. Next, you'll create a Definer
object.
Implement definer.go
In definer.go
, add the following code:
definer.go
package main
import (
"context"
"fmt"
"github.com/justtrackio/gosoline/pkg/cfg"
"github.com/justtrackio/gosoline/pkg/httpserver"
"github.com/justtrackio/gosoline/pkg/log"
)
func ApiDefiner(ctx context.Context, config cfg.Config, logger log.Logger) (*httpserver.Definitions, error) {
// Create an empty Definitions object, called definitions.
definitions := &httpserver.Definitions{}
// Create a new euroHandler.
euroHandler, err := NewEuroHandler(ctx, config, logger)
if err != nil {
return nil, fmt.Errorf("can not create euroHandler: %w", err)
}
// Create a new euroAtDateHandler.
euroAtDateHandler, err := NewEuroAtDateHandler(ctx, config, logger)
if err != nil {
return nil, fmt.Errorf("can not create euroAtDateHandler: %w", err)
}
// Add two routes to definitions. Each route handles GET requests. Notice that each route uses one of the handlers you wrote in handlers.go.
definitions.GET("/euro/:amount/:currency", httpserver.CreateHandler(euroHandler))
definitions.GET("/euro-at-date/:amount/:currency/:date", httpserver.CreateHandler(euroAtDateHandler))
// Return definitions.
return definitions, nil
}
Now, you'll walkthrough this file in detail to learn how it works.
Import dependencies
At the top of definer.go
, you declared the package and imported some dependencies:
package main
import (
"context"
"fmt"
"github.com/justtrackio/gosoline/pkg/cfg"
"github.com/justtrackio/gosoline/pkg/httpserver"
"github.com/justtrackio/gosoline/pkg/log"
)
Here, you declared the package as main
. Then, you imported the standard context
and fmt
modules along with three gosoline dependencies:
Implement a definer
Then, you implemented ApiDefiner
:
func ApiDefiner(ctx context.Context, config cfg.Config, logger log.Logger) (*httpserver.Definitions, error) {
// Create an empty Definitions object, called definitions.
definitions := &httpserver.Definitions{}
// Create a new euroHandler.
euroHandler, err := NewEuroHandler(ctx, config, logger)
if err != nil {
return nil, fmt.Errorf("can not create euroHandler: %w", err)
}
// Create a new euroAtDateHandler.
euroAtDateHandler, err := NewEuroAtDateHandler(ctx, config, logger)
if err != nil {
return nil, fmt.Errorf("can not create euroAtDateHandler: %w", err)
}
// Add two routes to definitions. Each route handles GET requests. Notice that each route uses one of the handlers you wrote in handlers.go.
definitions.GET("/euro/:amount/:currency", httpserver.CreateHandler(euroHandler))
definitions.GET("/euro-at-date/:amount/:currency/:date", httpserver.CreateHandler(euroAtDateHandler))
// Return definitions.
return definitions, nil
}
Here, the :amount
, :currency
, etc. constructs are path parameters. This means the handler will be able to access and use them. Indeed, you already implemented this behavior in your handler.go
file. euroHandler.Handle()
gets these values like this:
currency := request.Params.ByName("currency")
amountString := request.Params.ByName("amount")
Now that you've created handlers and a definer, create a configuration file.
Configure your server
In config.dist.yml
, configure your server:
env: dev
app_project: gosoline
app_family: example
app_group: money
app_name: exchange
httpserver:
default:
port: 8080
kvstore:
currency:
type: chain
in_memory:
max_size: 500000
application: money-exchange
elements: [inMemory]
ttl: 30m
Here, you set some minimal configurations for your web server. There are a few interesting configurations to note:
httpserver.default.port
exposes port 8080. (In your applications, you can configure more aspects of the server in a similar manner.)- The
currency
key value store (kvstore
) is defined asinMemory
and serves as a local database. Thecurrency
module that you used inhandler.go
uses this to store the exchange rates for various currencies:- First, it makes an initial call to an external endpoint in order to get exchange rates and stores them in a kvstore
- Later, it occasionally makes more calls to obtain exchange rates, in order to keep the kvstore updated
At this point, you've implemented your handlers, a definer, and your app configuration. Next, you'll implement main.go
that puts it all together.
Implement main.go
In main.go
, add the following code:
main.go
package main
import (
"github.com/justtrackio/gosoline/pkg/application"
"github.com/justtrackio/gosoline/pkg/currency"
"github.com/justtrackio/gosoline/pkg/httpserver"
)
func main() {
application.Run(
application.WithConfigFile("config.dist.yml", "yml"),
application.WithLoggerHandlersFromConfig,
application.WithModuleFactory("api", httpserver.New("default", ApiDefiner)),
application.WithModuleFactory("currency", currency.NewCurrencyModule()),
)
}
Now, you'll walkthrough this file in detail to learn how it works.
Import your dependencies
At the top of main.go
, you declared the package and imported some dependencies:
package main
import (
"github.com/justtrackio/gosoline/pkg/application"
"github.com/justtrackio/gosoline/pkg/currency"
"github.com/justtrackio/gosoline/pkg/httpserver"
)
Here, you declared the package as main
. Then, you imported three gosoline dependencies:
application
currency
httpserver
Implement main()
Then, you implemented the main entry point for your web service:
func main() {
application.Run(
application.WithConfigFile("config.dist.yml", "yml"),
application.WithLoggerHandlersFromConfig,
application.WithModuleFactory("api", httpserver.New("default", ApiDefiner)),
application.WithModuleFactory("currency", currency.NewCurrencyModule()),
)
}
Here, you run a kernel that uses config.dist.yml for its configuration and uses the api
and currency
modules.
Notice the api
module is using your ApiDefiner
that, in turn, uses your handlers.
Now that you've wired the application up, the final step is to test it to confirm that it works as expected.
Run your application
Initialize your go module, install the dependencies, and run your web service:
go mod init money_exchange/m
go mod tidy
go run .
In a separate terminal, make requests to your service:
curl localhost:8080/euro/10/GBP
curl localhost:8080/euro-at-date/10/USD/2021-01-03T00:00:00Z
Conclusion
Having seen a sample HTTP server, you can now look into more detailed functionality, such as writing integration tests. Check out these resources to learn more about creating web services with gosoline: