├── .github └── FUNDING.yml ├── .travis.yml ├── LICENSE ├── README.md ├── client.go ├── client_test.go ├── errors.go ├── errors_test.go ├── fixer.go └── fixer_test.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: peterhellberg 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | dist: bionic 4 | 5 | go: 6 | - "1.14.2" 7 | - "1.13.10" 8 | 9 | before_script: 10 | - go vet ./... 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2020 Peter Hellberg 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included 11 | in all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 15 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 18 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 19 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fixer 2 | 3 | [![Build Status](https://travis-ci.org/peterhellberg/fixer.svg?branch=master)](https://travis-ci.org/peterhellberg/fixer) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/peterhellberg/fixer)](https://goreportcard.com/report/github.com/peterhellberg/fixer) 5 | [![GoDoc](https://img.shields.io/badge/godoc-reference-blue.svg?style=flat)](https://godoc.org/github.com/peterhellberg/fixer) 6 | [![License MIT](https://img.shields.io/badge/license-MIT-lightgrey.svg?style=flat)](https://github.com/peterhellberg/fixer#license-mit) 7 | 8 | Go client for [Fixer.io](https://fixer.io/) (Foreign exchange rates and currency conversion API) 9 | 10 | > You need to [register](https://fixer.io/product) for a free access key if using the default Fixer API. 11 | 12 | The default client loads its access key from the environment variable `FIXER_ACCESS_KEY` 13 | 14 | ## Installation 15 | 16 | go get -u github.com/peterhellberg/fixer 17 | 18 | ## Usage examples 19 | 20 | **SEK quoted against USD and EUR** 21 | 22 | ```go 23 | package main 24 | 25 | import ( 26 | "context" 27 | "encoding/json" 28 | "os" 29 | 30 | "github.com/peterhellberg/fixer" 31 | ) 32 | 33 | func main() { 34 | resp, err := fixer.Latest(context.Background(), 35 | fixer.Base(fixer.SEK), 36 | fixer.Symbols( 37 | fixer.USD, 38 | fixer.EUR, 39 | ), 40 | ) 41 | if err != nil { 42 | return 43 | } 44 | 45 | encode(resp) 46 | } 47 | 48 | func encode(v interface{}) { 49 | enc := json.NewEncoder(os.Stdout) 50 | enc.SetEscapeHTML(false) 51 | enc.SetIndent("", " ") 52 | enc.Encode(v) 53 | } 54 | ``` 55 | 56 | ```json 57 | { 58 | "base": "SEK", 59 | "date": "2017-05-24T00:00:00Z", 60 | "rates": { 61 | "EUR": 0.10265, 62 | "USD": 0.1149 63 | }, 64 | "links": { 65 | "base": "https://api.fixer.io", 66 | "self": "https://api.fixer.io/latest?base=SEK&symbols=EUR%2CUSD" 67 | } 68 | } 69 | ``` 70 | 71 | **Using the [Foreign exchange rates API](https://exchangeratesapi.io/) instead** 72 | 73 | ```go 74 | package main 75 | 76 | import ( 77 | "context" 78 | "encoding/json" 79 | "os" 80 | "time" 81 | 82 | "github.com/peterhellberg/fixer" 83 | ) 84 | 85 | func main() { 86 | f := fixer.ExratesClient 87 | 88 | resp, err := f.At(context.Background(), time.Now(), 89 | fixer.Base(fixer.GBP), 90 | fixer.Symbols( 91 | fixer.SEK, 92 | fixer.NOK, 93 | ), 94 | ) 95 | if err != nil { 96 | return 97 | } 98 | 99 | encode(resp) 100 | } 101 | 102 | func encode(v interface{}) { 103 | enc := json.NewEncoder(os.Stdout) 104 | enc.SetEscapeHTML(false) 105 | enc.SetIndent("", " ") 106 | enc.Encode(v) 107 | } 108 | ``` 109 | 110 | ```json 111 | { 112 | "base": "GBP", 113 | "date": "2020-04-17T00:00:00Z", 114 | "rates": { 115 | "NOK": 12.9739704293, 116 | "SEK": 12.4799374554 117 | }, 118 | "links": { 119 | "base": "https://api.exchangeratesapi.io", 120 | "self": "https://api.exchangeratesapi.io/2020-04-18?base=GBP&symbols=NOK%2CSEK" 121 | } 122 | } 123 | ``` 124 | 125 | ## API documentation 126 | 127 | 128 | 129 | ## License (MIT) 130 | 131 | Copyright (c) 2017-2020 [Peter Hellberg](https://c7.se/) 132 | 133 | > Permission is hereby granted, free of charge, to any person obtaining 134 | > a copy of this software and associated documentation files (the "Software"), 135 | > to deal in the Software without restriction, including without limitation 136 | > the rights to use, copy, modify, merge, publish, distribute, sublicense, 137 | > and/or sell copies of the Software, and to permit persons to whom the 138 | > Software is furnished to do so, subject to the following conditions: 139 | > 140 | > The above copyright notice and this permission notice shall be included 141 | > in all copies or substantial portions of the Software. 142 | > 143 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 144 | > EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 145 | > OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 146 | > IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 147 | > DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 148 | > TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 149 | > OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 150 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package fixer 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "time" 12 | ) 13 | 14 | // FixerClient is a client configured to use https://api.fixer.io 15 | var FixerClient = NewClient(AccessKey(os.Getenv("FIXER_ACCESS_KEY"))) 16 | 17 | // ExratesClient is a client configured to use https://api.exchangeratesapi.io 18 | var ExratesClient = NewClient(BaseURL("https://api.exchangeratesapi.io")) 19 | 20 | // DefaultClient is the default client for the Foreign exchange rates and currency conversion API 21 | var DefaultClient = FixerClient 22 | 23 | // Client for the Foreign exchange rates and currency conversion API 24 | type Client struct { 25 | httpClient *http.Client 26 | baseURL *url.URL 27 | accessKey string 28 | userAgent string 29 | } 30 | 31 | // NewClient creates a Client 32 | func NewClient(options ...func(*Client)) *Client { 33 | c := &Client{ 34 | httpClient: &http.Client{ 35 | Timeout: 20 * time.Second, 36 | }, 37 | baseURL: &url.URL{ 38 | Scheme: "http", 39 | Host: "data.fixer.io", 40 | Path: "/api", 41 | }, 42 | accessKey: "", 43 | userAgent: "fixer/client.go (https://github.com/peterhellberg/fixer)", 44 | } 45 | 46 | for _, f := range options { 47 | f(c) 48 | } 49 | 50 | return c 51 | } 52 | 53 | // HTTPClient changes the HTTP client to the provided *http.Client 54 | func HTTPClient(hc *http.Client) func(*Client) { 55 | return func(c *Client) { 56 | c.httpClient = hc 57 | } 58 | } 59 | 60 | // BaseURL changes the base URL to the provided rawurl 61 | func BaseURL(rawurl string) func(*Client) { 62 | return func(c *Client) { 63 | if u, err := url.Parse(rawurl); err == nil { 64 | c.baseURL = u 65 | } 66 | } 67 | } 68 | 69 | // AccessKey sets the access key used by the client 70 | func AccessKey(ak string) func(*Client) { 71 | return func(c *Client) { 72 | c.accessKey = ak 73 | } 74 | } 75 | 76 | // UserAgent changes the User-Agent used by the client 77 | func UserAgent(ua string) func(*Client) { 78 | return func(c *Client) { 79 | c.userAgent = ua 80 | } 81 | } 82 | 83 | // Base sets the base query variable based on a Currency 84 | func Base(c Currency) url.Values { 85 | v := url.Values{} 86 | 87 | if s := string(c); s != "" { 88 | v.Set("base", s) 89 | } 90 | 91 | return v 92 | } 93 | 94 | // Symbols sets the symbols query variable based on the provided currencies 95 | func Symbols(cs ...Currency) url.Values { 96 | v := url.Values{} 97 | 98 | if s := Currencies(cs).String(); s != "" { 99 | v.Set("symbols", s) 100 | } 101 | 102 | return v 103 | } 104 | 105 | // Latest foreign exchange reference rates 106 | func (c *Client) Latest(ctx context.Context, attributes ...url.Values) (*Response, error) { 107 | return c.get(ctx, "/latest", c.query(attributes)) 108 | } 109 | 110 | // At returns historical rates for any day since 1999 111 | func (c *Client) At(ctx context.Context, t time.Time, attributes ...url.Values) (*Response, error) { 112 | return c.get(ctx, "/"+c.date(t), c.query(attributes)) 113 | } 114 | 115 | func (c *Client) date(t time.Time) string { 116 | return t.Format("2006-01-02") 117 | } 118 | 119 | func (c *Client) get(ctx context.Context, path string, query url.Values) (*Response, error) { 120 | req, err := c.request(ctx, path, query) 121 | if err != nil { 122 | return nil, err 123 | } 124 | 125 | r, err := c.do(req) 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | r.Links = Links{ 131 | "base": c.baseURL.String(), 132 | "self": req.URL.String(), 133 | } 134 | 135 | return r, nil 136 | } 137 | 138 | func (c *Client) query(attributes []url.Values) url.Values { 139 | v := url.Values{} 140 | 141 | for _, a := range attributes { 142 | if base := a.Get("base"); base != "" { 143 | v.Set("base", base) 144 | } 145 | 146 | if symbols := a.Get("symbols"); symbols != "" { 147 | v.Set("symbols", symbols) 148 | } 149 | } 150 | 151 | return v 152 | } 153 | 154 | func (c *Client) request(ctx context.Context, path string, query url.Values) (*http.Request, error) { 155 | rawurl := c.baseURL.Path + path 156 | 157 | if c.accessKey != "" { 158 | query.Set("access_key", c.accessKey) 159 | } 160 | 161 | if len(query) > 0 { 162 | rawurl += "?" + query.Encode() 163 | } 164 | 165 | rel, err := url.Parse(rawurl) 166 | if err != nil { 167 | return nil, err 168 | } 169 | 170 | req, err := http.NewRequest("GET", c.baseURL.ResolveReference(rel).String(), nil) 171 | if err != nil { 172 | return nil, err 173 | } 174 | 175 | req = req.WithContext(ctx) 176 | 177 | req.Header.Add("Accept", "application/json") 178 | req.Header.Add("User-Agent", c.userAgent) 179 | 180 | return req, nil 181 | } 182 | 183 | func (c *Client) do(req *http.Request) (*Response, error) { 184 | resp, err := c.httpClient.Do(req) 185 | if err != nil { 186 | return nil, err 187 | } 188 | defer func() { 189 | _, _ = io.CopyN(ioutil.Discard, resp.Body, 64) 190 | _ = resp.Body.Close() 191 | }() 192 | 193 | if err := responseError(resp); err != nil { 194 | return nil, err 195 | } 196 | 197 | var r Response 198 | 199 | if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { 200 | return nil, err 201 | } 202 | 203 | return &r, nil 204 | } 205 | 206 | // Latest foreign exchange reference rates using the DefaultClient 207 | func Latest(ctx context.Context, attributes ...url.Values) (*Response, error) { 208 | return DefaultClient.Latest(ctx, attributes...) 209 | } 210 | 211 | // At returns historical rates for any day since 1999 using the DefaultClient 212 | func At(ctx context.Context, t time.Time, attributes ...url.Values) (*Response, error) { 213 | return DefaultClient.At(ctx, t, attributes...) 214 | } 215 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package fixer 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "strings" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | func TestNewClient(t *testing.T) { 15 | t.Run("default", func(t *testing.T) { 16 | c := NewClient() 17 | 18 | if got, want := c.httpClient.Timeout, 20*time.Second; got != want { 19 | t.Fatalf("c.httpClient.Timeout = %q, want %q", got, want) 20 | } 21 | 22 | if got, want := c.baseURL.String(), "http://data.fixer.io/api"; got != want { 23 | t.Fatalf("c.baseURL.String() = %q, want %q", got, want) 24 | } 25 | 26 | if got, want := c.userAgent, "fixer/client.go (https://github.com/peterhellberg/fixer)"; got != want { 27 | t.Fatalf("c.userAgent = %q, want %q", got, want) 28 | } 29 | }) 30 | 31 | t.Run("HTTPClient", func(t *testing.T) { 32 | c := NewClient(HTTPClient(&http.Client{ 33 | Timeout: 40 * time.Second, 34 | })) 35 | 36 | if got, want := c.httpClient.Timeout, 40*time.Second; got != want { 37 | t.Fatalf("c.httpClient.Timeout = %q, want %q", got, want) 38 | } 39 | }) 40 | 41 | t.Run("BaseURL", func(t *testing.T) { 42 | rawurl := "https://api.exchangeratesapi.io" 43 | 44 | c := NewClient(BaseURL(rawurl)) 45 | 46 | if got, want := c.baseURL.String(), rawurl; got != want { 47 | t.Fatalf("c.baseURL.String() = %q, want %q", got, want) 48 | } 49 | }) 50 | 51 | t.Run("AccessKey", func(t *testing.T) { 52 | ak := "foo123" 53 | 54 | c := NewClient(AccessKey(ak)) 55 | 56 | if got, want := c.accessKey, ak; got != want { 57 | t.Fatalf("c.accessKey = %q, want %q", got, want) 58 | } 59 | }) 60 | 61 | t.Run("UserAgent", func(t *testing.T) { 62 | ua := "Custom User-Agent" 63 | 64 | c := NewClient(UserAgent(ua)) 65 | 66 | if got, want := c.userAgent, ua; got != want { 67 | t.Fatalf("c.userAgent = %q, want %q", got, want) 68 | } 69 | }) 70 | } 71 | 72 | func TestBase(t *testing.T) { 73 | for _, tt := range []struct { 74 | c Currency 75 | want string 76 | }{ 77 | {"", ""}, 78 | {SEK, "base=SEK"}, 79 | {EUR, "base=EUR"}, 80 | } { 81 | if got := Base(tt.c).Encode(); got != tt.want { 82 | t.Fatalf("Base(%v).Encode() = %q, want %q", tt.c, got, tt.want) 83 | } 84 | } 85 | } 86 | 87 | func TestSymbols(t *testing.T) { 88 | for _, tt := range []struct { 89 | currencies Currencies 90 | want string 91 | }{ 92 | {nil, ""}, 93 | {Currencies{}, ""}, 94 | {Currencies{SEK}, "symbols=SEK"}, 95 | {Currencies{SEK, DKK}, "symbols=DKK%2CSEK"}, 96 | {Currencies{EUR, USD, RUB}, "symbols=EUR%2CRUB%2CUSD"}, 97 | {Currencies{CAD, BGN, AUD}, "symbols=AUD%2CBGN%2CCAD"}, 98 | } { 99 | if got := Symbols(tt.currencies...).Encode(); got != tt.want { 100 | t.Fatalf("Symbols(%s).Encode() = %q, want %q", tt.currencies, got, tt.want) 101 | } 102 | } 103 | } 104 | 105 | func TestLatest(t *testing.T) { 106 | ts, c := testServerAndClient() 107 | defer ts.Close() 108 | 109 | y, m, d := time.Now().Date() 110 | 111 | t.Run("default", func(t *testing.T) { 112 | resp, err := c.Latest(context.Background()) 113 | if err != nil { 114 | t.Fatalf("unexpected error: %v", err) 115 | } 116 | 117 | if got, want := resp.Base, EUR; got != want { 118 | t.Fatalf("resp.Base = %q, want %q", got, want) 119 | } 120 | 121 | if got, want := resp.Date.Time, time.Date(y, m, d, 0, 0, 0, 0, time.UTC); got != want { 122 | t.Fatalf("resp.Date.Time = %v, want %v", got, want) 123 | } 124 | }) 125 | 126 | t.Run("base-SEK-symbols-USD-GBP", func(t *testing.T) { 127 | resp, err := c.Latest(context.Background(), Base(SEK), Symbols(USD, GBP)) 128 | if err != nil { 129 | t.Fatalf("unexpected error: %v", err) 130 | } 131 | 132 | if got, want := resp.Base, SEK; got != want { 133 | t.Fatalf("resp.Base = %q, want %q", got, want) 134 | } 135 | 136 | if got, want := resp.Date.Time, time.Date(y, m, d, 0, 0, 0, 0, time.UTC); got != want { 137 | t.Fatalf("resp.Date.Time = %v, want %v", got, want) 138 | } 139 | 140 | if got, want := len(resp.Rates), 2; got != want { 141 | t.Fatalf("len(%v) = %d, want %d", resp.Rates, got, want) 142 | } 143 | 144 | if got, want := resp.Rates[GBP], 0.088628; got != want { 145 | t.Fatalf("resp.Rates[GBP] = %f, want %f", got, want) 146 | } 147 | }) 148 | } 149 | 150 | func TestAt(t *testing.T) { 151 | ts, c := testServerAndClient() 152 | defer ts.Close() 153 | 154 | t.Run("2012-03-28", func(t *testing.T) { 155 | date := time.Date(2012, 3, 28, 0, 0, 0, 0, time.UTC) 156 | 157 | resp, err := c.At(context.Background(), date) 158 | if err != nil { 159 | t.Fatalf("unexpected error: %v", err) 160 | } 161 | 162 | if got, want := resp.Base, EUR; got != want { 163 | t.Fatalf("resp.Base = %q, want %q", got, want) 164 | } 165 | 166 | if got, want := resp.Date.Time, date; got != want { 167 | t.Fatalf("resp.Date.Time = %v, want %v", got, want) 168 | } 169 | }) 170 | 171 | t.Run("too-old", func(t *testing.T) { 172 | date := time.Date(1999, 12, 31, 0, 0, 0, 0, time.UTC) 173 | 174 | if _, err := c.At(context.Background(), date); err != ErrUnprocessableEntity { 175 | t.Fatalf("unexpected error: %v", err) 176 | } 177 | }) 178 | 179 | t.Run("not-json", func(t *testing.T) { 180 | date := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) 181 | 182 | _, err := c.At(context.Background(), date) 183 | 184 | if err == nil { 185 | t.Fatalf("expected to get error") 186 | } 187 | 188 | if got, want := err.Error(), "invalid character 'N' looking for beginning of value"; got != want { 189 | t.Fatalf("err.Error() = %q, want %q", got, want) 190 | } 191 | }) 192 | } 193 | 194 | func TestGet(t *testing.T) { 195 | ts, c := testServerAndClient() 196 | defer ts.Close() 197 | 198 | t.Run("invalid-request", func(t *testing.T) { 199 | _, err := c.get(context.Background(), ":/", url.Values{}) 200 | 201 | if err == nil { 202 | t.Fatalf("expected to get error") 203 | } 204 | 205 | got := err.Error() 206 | want := "missing protocol scheme" 207 | 208 | if !strings.Contains(err.Error(), want) { 209 | t.Fatalf("err.Error() = %q, want %q", got, want) 210 | } 211 | }) 212 | } 213 | 214 | func testServerAndClient() (*httptest.Server, *Client) { 215 | ts := httptest.NewServer(http.HandlerFunc( 216 | func(w http.ResponseWriter, r *http.Request) { 217 | enc := json.NewEncoder(w) 218 | 219 | w.Header().Set("Content-Type", "application/json") 220 | 221 | switch r.URL.String() { 222 | case "/latest": 223 | enc.Encode(map[string]interface{}{ 224 | "base": EUR, 225 | "date": time.Now().Format("2006-01-02"), 226 | }) 227 | case "/latest?base=SEK&symbols=GBP%2CUSD": 228 | enc.Encode(map[string]interface{}{ 229 | "base": SEK, 230 | "date": time.Now().Format("2006-01-02"), 231 | "rates": Rates{ 232 | GBP: 0.088628, 233 | USD: 0.1149, 234 | }, 235 | }) 236 | case "/2012-03-28": 237 | enc.Encode(map[string]interface{}{ 238 | "base": EUR, 239 | "date": "2012-03-28", 240 | }) 241 | case "/1999-12-31": 242 | w.WriteHeader(http.StatusUnprocessableEntity) 243 | case "/2000-01-01": 244 | w.Write([]byte(`NOT JSON`)) 245 | default: 246 | w.WriteHeader(http.StatusNotFound) 247 | } 248 | })) 249 | 250 | return ts, NewClient(BaseURL(ts.URL)) 251 | } 252 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package fixer 2 | 3 | import "net/http" 4 | 5 | // NewError creates a new Error 6 | func NewError(msg string) *Error { 7 | return &Error{msg: msg} 8 | } 9 | 10 | // Error type for Fixer API requests 11 | type Error struct { 12 | msg string 13 | } 14 | 15 | // Error message 16 | func (e *Error) Error() string { 17 | return e.msg 18 | } 19 | 20 | // Errors 21 | var ( 22 | ErrNilResponse = NewError("Unexpected nil response") 23 | ErrUnexpectedStatus = NewError("Unexpected status") 24 | ErrNotFound = NewError(http.StatusText(http.StatusNotFound)) 25 | ErrUnprocessableEntity = NewError(http.StatusText(http.StatusUnprocessableEntity)) 26 | ) 27 | 28 | func responseError(resp *http.Response) error { 29 | if resp == nil { 30 | return ErrNilResponse 31 | } 32 | 33 | switch resp.StatusCode { 34 | case http.StatusOK: 35 | return nil 36 | case http.StatusNotFound: 37 | return ErrNotFound 38 | case http.StatusUnprocessableEntity: 39 | return ErrUnprocessableEntity 40 | default: 41 | return ErrUnexpectedStatus 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /errors_test.go: -------------------------------------------------------------------------------- 1 | package fixer 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | ) 7 | 8 | func TestNewError(t *testing.T) { 9 | for _, msg := range []string{"", "foo", "bar"} { 10 | if got := NewError(msg).Error(); got != msg { 11 | t.Fatalf("NewError(%q).Error() = %q, want %q", msg, got, msg) 12 | } 13 | } 14 | } 15 | 16 | func TestResponseError(t *testing.T) { 17 | for _, tt := range []struct { 18 | resp *http.Response 19 | want error 20 | }{ 21 | {nil, ErrNilResponse}, 22 | {&http.Response{}, ErrUnexpectedStatus}, 23 | {&http.Response{StatusCode: 200}, nil}, 24 | {&http.Response{StatusCode: 404}, ErrNotFound}, 25 | {&http.Response{StatusCode: 422}, ErrUnprocessableEntity}, 26 | } { 27 | if got := responseError(tt.resp); got != tt.want { 28 | t.Fatalf("responseError(tt.resp) = %v, want %v", got, tt.want) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /fixer.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Package fixer contains a client for the 4 | Foreign exchange rates and currency conversion API 5 | 6 | Installation 7 | 8 | go get -u github.com/peterhellberg/fixer 9 | 10 | Usage 11 | 12 | A small usage example 13 | 14 | package main 15 | 16 | import ( 17 | "context" 18 | "flag" 19 | "fmt" 20 | 21 | "github.com/peterhellberg/fixer" 22 | ) 23 | 24 | func main() { 25 | f := flag.String("from", "EUR", "") 26 | t := flag.String("to", "SEK", "") 27 | n := flag.Float64("n", 1, "") 28 | 29 | flag.Parse() 30 | 31 | from, to := fixer.Currency(*f), fixer.Currency(*t) 32 | 33 | resp, err := fixer.Latest(context.Background(), 34 | fixer.Base(from), fixer.Symbols(to), 35 | ) 36 | 37 | if err == nil { 38 | fmt.Printf("%.2f %s equals %.2f %s\n", *n, from, resp.Rates[to]**n, to) 39 | } 40 | } 41 | 42 | API Documentation 43 | 44 | http://fixer.io/ 45 | 46 | */ 47 | package fixer 48 | 49 | import ( 50 | "encoding/json" 51 | "sort" 52 | "strings" 53 | "time" 54 | ) 55 | 56 | // Date wraps time.Time 57 | type Date struct { 58 | time.Time 59 | } 60 | 61 | // UnmarshalJSON parses dates in YYYY-MM-DD format 62 | func (d *Date) UnmarshalJSON(b []byte) error { 63 | var value string 64 | 65 | err := json.Unmarshal(b, &value) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | t, err := time.ParseInLocation("2006-01-02", value, time.UTC) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | *d = Date{t} 76 | 77 | return nil 78 | } 79 | 80 | // Rates is the list of rates quoted against the base (EUR by default) 81 | type Rates map[Currency]float64 82 | 83 | // Links is a links object related to the primary data of the Response 84 | type Links map[string]string 85 | 86 | // Response data from the Foreign exchange rates and currency conversion API 87 | type Response struct { 88 | Base Currency `json:"base"` 89 | Date Date `json:"date"` 90 | Rates Rates `json:"rates"` 91 | Links Links `json:"links,omitempty"` 92 | } 93 | 94 | // Currencies is a slice of Currency 95 | type Currencies []Currency 96 | 97 | func (cs Currencies) String() string { 98 | symbols := []string{} 99 | 100 | for _, c := range cs { 101 | symbols = append(symbols, string(c)) 102 | } 103 | 104 | sort.Strings(symbols) 105 | 106 | return strings.Join(symbols, ",") 107 | } 108 | 109 | // Currency is the type used for ISO 4217 Currency codes 110 | type Currency string 111 | 112 | // Currency codes published by the European Central Bank 113 | const ( 114 | AED Currency = "AED" 115 | AFN Currency = "AFN" 116 | ALL Currency = "ALL" 117 | AMD Currency = "AMD" 118 | ANG Currency = "ANG" 119 | AOA Currency = "AOA" 120 | ARS Currency = "ARS" 121 | AUD Currency = "AUD" 122 | AWG Currency = "AWG" 123 | AZN Currency = "AZN" 124 | BAM Currency = "BAM" 125 | BBD Currency = "BBD" 126 | BDT Currency = "BDT" 127 | BGN Currency = "BGN" 128 | BHD Currency = "BHD" 129 | BIF Currency = "BIF" 130 | BMD Currency = "BMD" 131 | BND Currency = "BND" 132 | BOB Currency = "BOB" 133 | BRL Currency = "BRL" 134 | BSD Currency = "BSD" 135 | BTC Currency = "BTC" 136 | BTN Currency = "BTN" 137 | BWP Currency = "BWP" 138 | BYN Currency = "BYN" 139 | BYR Currency = "BYR" 140 | BZD Currency = "BZD" 141 | CAD Currency = "CAD" 142 | CDF Currency = "CDF" 143 | CHF Currency = "CHF" 144 | CLF Currency = "CLF" 145 | CLP Currency = "CLP" 146 | CNY Currency = "CNY" 147 | COP Currency = "COP" 148 | CRC Currency = "CRC" 149 | CUC Currency = "CUC" 150 | CUP Currency = "CUP" 151 | CVE Currency = "CVE" 152 | CZK Currency = "CZK" 153 | DJF Currency = "DJF" 154 | DKK Currency = "DKK" 155 | DOP Currency = "DOP" 156 | DZD Currency = "DZD" 157 | EGP Currency = "EGP" 158 | ERN Currency = "ERN" 159 | ETB Currency = "ETB" 160 | EUR Currency = "EUR" 161 | FJD Currency = "FJD" 162 | FKP Currency = "FKP" 163 | GBP Currency = "GBP" 164 | GEL Currency = "GEL" 165 | GGP Currency = "GGP" 166 | GHS Currency = "GHS" 167 | GIP Currency = "GIP" 168 | GMD Currency = "GMD" 169 | GNF Currency = "GNF" 170 | GTQ Currency = "GTQ" 171 | GYD Currency = "GYD" 172 | HKD Currency = "HKD" 173 | HNL Currency = "HNL" 174 | HRK Currency = "HRK" 175 | HTG Currency = "HTG" 176 | HUF Currency = "HUF" 177 | IDR Currency = "IDR" 178 | ILS Currency = "ILS" 179 | IMP Currency = "IMP" 180 | INR Currency = "INR" 181 | IQD Currency = "IQD" 182 | IRR Currency = "IRR" 183 | ISK Currency = "ISK" 184 | JEP Currency = "JEP" 185 | JMD Currency = "JMD" 186 | JOD Currency = "JOD" 187 | JPY Currency = "JPY" 188 | KES Currency = "KES" 189 | KGS Currency = "KGS" 190 | KHR Currency = "KHR" 191 | KMF Currency = "KMF" 192 | KPW Currency = "KPW" 193 | KRW Currency = "KRW" 194 | KWD Currency = "KWD" 195 | KYD Currency = "KYD" 196 | KZT Currency = "KZT" 197 | LAK Currency = "LAK" 198 | LBP Currency = "LBP" 199 | LKR Currency = "LKR" 200 | LRD Currency = "LRD" 201 | LSL Currency = "LSL" 202 | LTL Currency = "LTL" 203 | LVL Currency = "LVL" 204 | LYD Currency = "LYD" 205 | MAD Currency = "MAD" 206 | MDL Currency = "MDL" 207 | MGA Currency = "MGA" 208 | MKD Currency = "MKD" 209 | MMK Currency = "MMK" 210 | MNT Currency = "MNT" 211 | MOP Currency = "MOP" 212 | MRO Currency = "MRO" 213 | MUR Currency = "MUR" 214 | MVR Currency = "MVR" 215 | MWK Currency = "MWK" 216 | MXN Currency = "MXN" 217 | MYR Currency = "MYR" 218 | MZN Currency = "MZN" 219 | NAD Currency = "NAD" 220 | NGN Currency = "NGN" 221 | NIO Currency = "NIO" 222 | NOK Currency = "NOK" 223 | NPR Currency = "NPR" 224 | NZD Currency = "NZD" 225 | OMR Currency = "OMR" 226 | PAB Currency = "PAB" 227 | PEN Currency = "PEN" 228 | PGK Currency = "PGK" 229 | PHP Currency = "PHP" 230 | PKR Currency = "PKR" 231 | PLN Currency = "PLN" 232 | PYG Currency = "PYG" 233 | QAR Currency = "QAR" 234 | RON Currency = "RON" 235 | RSD Currency = "RSD" 236 | RUB Currency = "RUB" 237 | RWF Currency = "RWF" 238 | SAR Currency = "SAR" 239 | SBD Currency = "SBD" 240 | SCR Currency = "SCR" 241 | SDG Currency = "SDG" 242 | SEK Currency = "SEK" 243 | SGD Currency = "SGD" 244 | SHP Currency = "SHP" 245 | SLL Currency = "SLL" 246 | SOS Currency = "SOS" 247 | SRD Currency = "SRD" 248 | STD Currency = "STD" 249 | SVC Currency = "SVC" 250 | SYP Currency = "SYP" 251 | SZL Currency = "SZL" 252 | THB Currency = "THB" 253 | TJS Currency = "TJS" 254 | TMT Currency = "TMT" 255 | TND Currency = "TND" 256 | TOP Currency = "TOP" 257 | TRY Currency = "TRY" 258 | TTD Currency = "TTD" 259 | TWD Currency = "TWD" 260 | TZS Currency = "TZS" 261 | UAH Currency = "UAH" 262 | UGX Currency = "UGX" 263 | USD Currency = "USD" 264 | UYU Currency = "UYU" 265 | UZS Currency = "UZS" 266 | VEF Currency = "VEF" 267 | VND Currency = "VND" 268 | VUV Currency = "VUV" 269 | WST Currency = "WST" 270 | XAF Currency = "XAF" 271 | XAG Currency = "XAG" 272 | XAU Currency = "XAU" 273 | XCD Currency = "XCD" 274 | XDR Currency = "XDR" 275 | XOF Currency = "XOF" 276 | XPF Currency = "XPF" 277 | YER Currency = "YER" 278 | ZAR Currency = "ZAR" 279 | ZMK Currency = "ZMK" 280 | ZMW Currency = "ZMW" 281 | ZWL Currency = "ZWL" 282 | ) 283 | -------------------------------------------------------------------------------- /fixer_test.go: -------------------------------------------------------------------------------- 1 | package fixer 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestDateUnmarshalJSON(t *testing.T) { 11 | for _, tt := range []struct { 12 | s string 13 | want time.Time 14 | }{ 15 | {`{"date":false}`, time.Time{}}, 16 | {`{"date":"not-a-date"}`, time.Time{}}, 17 | {`{"date":"2015-01-06"}`, time.Date(2015, 1, 6, 0, 0, 0, 0, time.UTC)}, 18 | {`{"date":"2016-02-12"}`, time.Date(2016, 2, 12, 0, 0, 0, 0, time.UTC)}, 19 | {`{"date":"2017-03-24"}`, time.Date(2017, 3, 24, 0, 0, 0, 0, time.UTC)}, 20 | } { 21 | var v struct { 22 | Date Date `json:"date"` 23 | } 24 | 25 | json.NewDecoder(strings.NewReader(tt.s)).Decode(&v) 26 | 27 | if got := v.Date.Time; !got.Equal(tt.want) { 28 | t.Fatalf("v.Date.Time = %v, want %v", got, tt.want) 29 | } 30 | } 31 | } 32 | 33 | func TestCurrenciesString(t *testing.T) { 34 | for _, tt := range []struct { 35 | cs Currencies 36 | want string 37 | }{ 38 | {nil, ""}, 39 | {Currencies{}, ""}, 40 | {Currencies{SEK, DKK}, "DKK,SEK"}, 41 | {Currencies{USD, AUD, EUR}, "AUD,EUR,USD"}, 42 | } { 43 | if got := tt.cs.String(); got != tt.want { 44 | t.Fatalf("(Currencies{%s}).String() = %q, want %q", tt.cs, got, tt.want) 45 | } 46 | } 47 | } 48 | --------------------------------------------------------------------------------