Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
mattevans committed Dec 16, 2016
0 parents commit ef002e3
Show file tree
Hide file tree
Showing 9 changed files with 569 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .travis.yml
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
19 changes: 19 additions & 0 deletions LICENSE
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.
75 changes: 75 additions & 0 deletions README.md
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()
```
60 changes: 60 additions & 0 deletions cache.go
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
}
41 changes: 41 additions & 0 deletions cache_test.go
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))
}
172 changes: 172 additions & 0 deletions dinero.go
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
}
Loading

0 comments on commit ef002e3

Please sign in to comment.