-
-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit ef002e3
Showing
9 changed files
with
569 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
sudo: false | ||
language: go | ||
go: | ||
- 1.4 | ||
- 1.5 | ||
- 1.6 | ||
- 1.7 | ||
- tip |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
Copyright (C) 2016 by Matt Evans | ||
|
||
Permission is hereby granted, free of charge, to any person obtaining a copy | ||
of this software and associated documentation files (the "Software"), to deal | ||
in the Software without restriction, including without limitation the rights | ||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
copies of the Software, and to permit persons to whom the Software is | ||
furnished to do so, subject to the following conditions: | ||
|
||
The above copyright notice and this permission notice shall be included in | ||
all copies or substantial portions of the Software. | ||
|
||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN | ||
THE SOFTWARE. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
# dinero | ||
|
||
dinero is a [Go](http://golang.org) client library for accessing the Open Exchange Rates API (https://docs.openexchangerates.org/docs/). | ||
|
||
Upon request of forex rates these will be cached (in-memory), keyed by base currency. With a two hour expiry window, subsequent requests will use cached data or fetch fresh data accordingly. | ||
|
||
Installation | ||
----------------- | ||
|
||
`go get -u github.com/mattevans/dinero` | ||
|
||
Usage | ||
----------------- | ||
|
||
**Get All** | ||
|
||
```go | ||
// Init dinero client. | ||
client := NewClient(os.Getenv("OPEN_EXCHANGE_APP_ID")) | ||
|
||
// Set a base currency to work with. | ||
client.Rates.SetBaseCurrency("AUD") | ||
|
||
// Get latest forex rates using AUD as a base currency. | ||
response, err := client.Rates.All() | ||
if err != nil { | ||
return err | ||
} | ||
``` | ||
|
||
```json | ||
{ | ||
"rates":{ | ||
"AED":2.702388, | ||
"AFN":48.893275, | ||
"ALL":95.142814, | ||
"AMD":356.88691, | ||
... | ||
}, | ||
"updated_at":"2016-12-16T11:25:47.38290048+13:00", | ||
"base":"AUD" | ||
} | ||
``` | ||
|
||
--- | ||
|
||
**Get Single** | ||
|
||
```go | ||
// Init dinero client. | ||
client := NewClient(os.Getenv("OPEN_EXCHANGE_APP_ID")) | ||
|
||
// Set a base currency to work with. | ||
client.Rates.SetBaseCurrency("AUD") | ||
|
||
// Get latest forex rate for NZD using AUD as a base currency. | ||
response, err := client.Rates.Single("NZD") | ||
if err != nil { | ||
return err | ||
} | ||
``` | ||
|
||
```json | ||
1.045545 | ||
``` | ||
|
||
--- | ||
|
||
**Expire in-memory cache** | ||
|
||
The following will expire the in-memory cache for the set base currency. | ||
|
||
```go | ||
client.Cache.Expire() | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
package dinero | ||
|
||
import "time" | ||
|
||
// cache holds our our cached forex results for varying bases. | ||
var cache map[string]*RatesStore | ||
|
||
// cacheTTL stores the time to live of our cache (2 hours). | ||
var cacheTTL = 2 * time.Hour | ||
|
||
// CacheService handles in-memory caching of our exchange rates. | ||
type CacheService service | ||
|
||
// Get will return our stored in-memory forex rates, if we have them. | ||
func (s *CacheService) Get(base string) *RatesStore { | ||
// Is our cache expired? | ||
if s.IsExpired(base) { | ||
return nil | ||
} | ||
|
||
// Use stored results. | ||
return cache[base] | ||
} | ||
|
||
// Store will save our forex rates to a RatesStore. | ||
func (s *CacheService) Store(base string, rates map[string]float64) { | ||
// No cache? Initalize it. | ||
if cache == nil { | ||
cache = map[string]*RatesStore{} | ||
} | ||
|
||
// Store | ||
tn := time.Now() | ||
cache[base] = &RatesStore{ | ||
Rates: rates, | ||
UpdatedAt: &tn, | ||
Base: base, | ||
} | ||
} | ||
|
||
// IsExpired checks if we have stored cache and that it isn't expired. | ||
func (s *CacheService) IsExpired(base string) bool { | ||
// No cache? bail. | ||
if cache[base] == nil || (len(cache[base].Rates) <= 0) { | ||
return true | ||
} | ||
|
||
// Expired cache? bail. | ||
lastUpdated := cache[base].UpdatedAt | ||
if lastUpdated != nil && lastUpdated.Add(cacheTTL).Before(time.Now()) { | ||
return true | ||
} | ||
|
||
return false | ||
} | ||
|
||
// Expire will expire the cache for a given base currency. | ||
func (s *CacheService) Expire(base string) { | ||
cache[base] = nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
package dinero | ||
|
||
import ( | ||
"encoding/json" | ||
"os" | ||
"testing" | ||
|
||
. "github.com/onsi/gomega" | ||
) | ||
|
||
// TestCache will test that our in-memory cache of forex results is working. | ||
func TestCache(t *testing.T) { | ||
// Register the test. | ||
RegisterTestingT(t) | ||
|
||
// Init dinero client. | ||
client := NewClient(os.Getenv("OPEN_EXCHANGE_APP_ID")) | ||
|
||
// Set a base currency to work with. | ||
client.Rates.SetBaseCurrency("AUD") | ||
|
||
// Get latest forex rates. | ||
response1, err := client.Rates.All() | ||
if err != nil { | ||
t.Fatalf("Unexpected error running client.Rates.All(): %s", err.Error()) | ||
} | ||
|
||
// Expire the cache | ||
client.Cache.Expire("AUD") | ||
|
||
// Fetch results again | ||
response2, err := client.Rates.All() | ||
if err != nil { | ||
t.Fatalf("Unexpected error running client.Rates.All(): %s", err.Error()) | ||
} | ||
|
||
// Compare the results, they shouldn't match, as update_at values will differ. | ||
first, _ := json.Marshal(response1) | ||
second, _ := json.Marshal(response2) | ||
Expect(first).NotTo(MatchJSON(second)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,172 @@ | ||
package dinero | ||
|
||
import ( | ||
"bytes" | ||
"encoding/json" | ||
"fmt" | ||
"io" | ||
"io/ioutil" | ||
"net/http" | ||
"net/url" | ||
) | ||
|
||
const ( | ||
packageVersion = "0.1.0" | ||
backendURL = "http://openexchangerates.org" | ||
userAgent = "dinero/" + packageVersion | ||
) | ||
|
||
// Client holds a connection to the OXR API. | ||
type Client struct { | ||
client *http.Client | ||
AppID string | ||
UserAgent string | ||
BackendURL *url.URL | ||
|
||
// Services used for communicating with the API. | ||
Rates *RatesService | ||
Update *UpdateService | ||
Cache *CacheService | ||
} | ||
|
||
type service struct { | ||
client *Client | ||
} | ||
|
||
// Response is a OXR response. This wraps the standard http.Response | ||
// returned from the OXR API. | ||
type Response struct { | ||
*http.Response | ||
ErrorCode int64 | ||
Message string | ||
} | ||
|
||
// An ErrorResponse reports the error caused by an API request | ||
type ErrorResponse struct { | ||
*http.Response | ||
ErrorCode int64 `json:"status"` | ||
Message string `json:"message"` | ||
Description string `json:"description"` | ||
} | ||
|
||
func (r *ErrorResponse) Error() string { | ||
return fmt.Sprintf("%d %v", r.Response.StatusCode, r.Description) | ||
} | ||
|
||
// NewClient creates a new Client with the appropriate connection details and | ||
// services used for communicating with the API. | ||
func NewClient(oxrAppID string) *Client { | ||
// Init new http.Client. | ||
httpClient := http.DefaultClient | ||
|
||
// Parse BE URL. | ||
baseURL, _ := url.Parse(backendURL) | ||
|
||
c := &Client{ | ||
client: httpClient, | ||
BackendURL: baseURL, | ||
UserAgent: userAgent, | ||
AppID: oxrAppID, | ||
} | ||
|
||
c.Update = &UpdateService{client: c} | ||
c.Rates = &RatesService{client: c} | ||
c.Cache = &CacheService{client: c} | ||
return c | ||
} | ||
|
||
// NewRequest creates an API request. A relative URL can be provided in urlPath, | ||
// which will be resolved to the BackendURL of the Client. | ||
func (c *Client) NewRequest(method, urlPath string, body interface{}) (*http.Request, error) { | ||
// Append out OXR App ID to URL, :-( | ||
urlPath = fmt.Sprintf("%s&app_id=%s", urlPath, c.AppID) | ||
|
||
// Parse our URL. | ||
rel, err := url.Parse(urlPath) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
// Resolve to absolute URI. | ||
u := c.BackendURL.ResolveReference(rel) | ||
|
||
buf := new(bytes.Buffer) | ||
if body != nil { | ||
err = json.NewEncoder(buf).Encode(body) | ||
if err != nil { | ||
return nil, err | ||
} | ||
} | ||
|
||
// Create the request. | ||
req, err := http.NewRequest(method, u.String(), buf) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
// Add our libraries UA. | ||
req.Header.Add("User-Agent", c.UserAgent) | ||
|
||
return req, nil | ||
} | ||
|
||
// Do sends an API request and returns the API response. The API response is | ||
// JSON decoded and stored in 'v', or returned as an error if an API (if found). | ||
func (c *Client) Do(req *http.Request, v interface{}) (*Response, error) { | ||
resp, err := c.client.Do(req) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
defer func() { | ||
if rerr := resp.Body.Close(); err == nil { | ||
err = rerr | ||
} | ||
}() | ||
|
||
// Wrap our response. | ||
response := &Response{Response: resp} | ||
|
||
// Check for any errors that may have occurred. | ||
err = CheckResponse(resp) | ||
if err != nil { | ||
return response, err | ||
} | ||
|
||
if v != nil { | ||
if w, ok := v.(io.Writer); ok { | ||
_, err = io.Copy(w, resp.Body) | ||
if err != nil { | ||
return nil, err | ||
} | ||
} else { | ||
err = json.NewDecoder(resp.Body).Decode(&v) | ||
if err != nil { | ||
return nil, err | ||
} | ||
} | ||
|
||
} | ||
|
||
return response, err | ||
} | ||
|
||
// CheckResponse checks the API response for errors. A response is considered an | ||
// error if it has a status code outside the 200 range. API error responses map | ||
// to ErrorResponse. | ||
func CheckResponse(r *http.Response) error { | ||
if c := r.StatusCode; c >= 200 && c <= 299 { | ||
return nil | ||
} | ||
|
||
errorResponse := &ErrorResponse{Response: r} | ||
|
||
data, err := ioutil.ReadAll(r.Body) | ||
if err == nil && len(data) > 0 { | ||
err := json.Unmarshal(data, errorResponse) | ||
if err != nil { | ||
return err | ||
} | ||
} | ||
return errorResponse | ||
} |
Oops, something went wrong.