Fetching currency rates in Go - part 2 parsing the XML
At the end of Part 1 I had just completed my first test for parsing XML from the European Central Bank (ECB) currency rate service.
Reading XML
f, err := os.Open("testdata/eurofxref-daily.xml")
if err != nil {
t.Fatal(err)
}
defer f.Close()
rates, err := parse(f)
That call says that I want my parse function to take an open file.
The documentation for Open says that it returns a *File object.
The documentation for the XML Decoder says that it requires an io.Reader, so, I go and check what io.Reader` needs, and it’s fairly clear:
type Reader interface {
Read(p []byte) (n int, err error)
}
By comparing this with the declaration of File.Read I can see that it clearly implements the same method.
Go’s interfaces are a formalisation of duck-typing, our File provides a way to read into a slice buffer, and that’s all the XML decoder needs.
Implementing the parser was a bit trial and error, and that initial test was very helpful in getting to the result.
The Parser
func parse(f io.Reader) ([]*EuroRate, error) {
decoder := xml.NewDecoder(f)
rates := make([]*EuroRate, 0)
var currentDate string
for {
token, err := decoder.Token()
if err != nil {
return nil, err
}
if err == io.EOF || token == nil {
break
}
switch se := token.(type) {
case xml.StartElement:
if se.Name.Local == "Cube" {
date := getDateFromElement(se)
if date != "" {
currentDate = date
} else {
rate := getEuroRateFromElement(se, currentDate)
if rate != nil {
if currentDate == "" {
return nil, errors.New("invalid XML: found rate without current date")
}
rates = append(rates, rate)
}
}
}
}
}
return rates, nil
}
It starts by wrapping the provided io.Reader
in an XML decoder.
Then creates a new slice of pointers to EuroRate
structs, with size 0.
The parser doesn’t know how many items it will find up front, and the slice will dynamically grow, there could be some performance optimisation here to put in a default number of currencies as the size, and let it grow if I get more, but for this initial implementation, this isn’t important.
There’s a fair bit of error handling when decoding tokens from the stream, if I get an error, I return the error up the call stack.
This code all uses the stdlib, but in production, I’d recommend the errors
package,
which can wrap errors making it easier to get list of the functions that an
error passed through, a common mistake in Go is to just return the error that
you get from an external call, and all that bubbles up to the top is the
message, something like could not open File
, without any easy way to identify
which file, or where it couldn’t be opened.
After that, I use a type switch to identify StartElement
tokens.
The rest of the function ensures that I’m parsing the XML correctly, and
instantiates EuroRate
objects from the data in the XML token, and then appends
it to the slice that is eventually returned.
// tries to extract a EuroRate from the provided element
// the element may not be a valid rate element (they're all called Cube) and in
// this case, returns nil.
func getEuroRateFromElement(se xml.StartElement, date string) *EuroRate {
var code string
var rate string
for _, attr := range se.Attr {
switch attr.Name.Local {
case "currency":
code = attr.Value
case "rate":
rate = attr.Value
}
}
if code != "" && rate != "" && date != "" {
return &EuroRate{Currency: code, Rate: rate, ReferenceDate: date}
}
return nil
}
Tries to extract a EuroRate from this XML.
<Cube currency='USD' rate='1.1243'/>
This code parses the attributes from the XML element , and returns valid EuroRate
values from the items.
If it can’t find the relevant attributes, this might not be a rate element, so I can skip this this one.
At this point, I have a simple function that can take an object that can be read from, and parse XML from it.
Further tests to validate the parsing
func TestParseDailyRatesCurrencies(t *testing.T) {
f, err := os.Open("testdata/eurofxref-daily.xml")
if err != nil {
t.Fatal(err)
}
defer f.Close()
rates, err := parse(f)
ratesToCheck := []struct {
element int
currency string
rate string
date string
}{
{0, "USD", "1.1243", "2017-05-21"},
}
for _, tt := range ratesToCheck {
rate := rates[tt.element]
if rate.Currency != tt.currency {
t.Errorf("unexpected currency parsed (%d): got %s, wanted %s", tt.element, rate.Currency, tt.currency)
}
if rate.Rate != tt.rate {
t.Errorf("unexpected rate parsed (%d): got %s, wanted %s", tt.element, rate.Rate, tt.rate)
}
if rate.ReferenceDate != tt.date {
t.Errorf("unexpected date parsed (%d): got %s, wanted %s", tt.element, rate.ReferenceDate, tt.date)
}
}
}
This uses a very idiomatic Go testing method, Table Driven Tests.
The ratesToCheck := []struct{ ... }
declaration creates an anonymous struct,
with four members, element, currency, rate and date.
It then provides struct literals, {0, "USD", "1.1243", "2017-05-22"}
to
populate an array literal (the []struct
) declaration.
The actual tests, iterate over the table, asserting the rate I got, matched what I expected.
Table tests are really useful, they make it easy to extend your tests and add additional cases, and can often be shared amongst several tests, by adding extra columns.
Junit 5 has a version of these, but in my experience, they’re close, but not quite as nice.
Some other things to note from these tests:
-
These use Errorf instead of Fatalf, the Fatal functions in the testing packge are assertions, rather than checks.
This means that all of the tests in the table are guaranteed to run, rather than stopping after the first one.
-
They include the element number in the output of the test, so that you can work out which ones went wrong.
-
Finally, they use a very terse output message, “got %s, wanted %s”, this is fairly common in Go, see here and here for examples from the standard library.