exchange 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,23 +13,37 @@ module Exchange
13
13
  # # Define here which currencies your API can handle
14
14
  # CURRENCIES = %W(usd chf)
15
15
  #
16
- # # Every instance of ExternalAPI Class has to have an update function which gets the rates from the API
16
+ # # Every instance of ExternalAPI Class has to have an update function which
17
+ # # gets the rates from the API
18
+ # #
17
19
  # def update(opts={})
18
20
  # # assure that you will get a Time object for the historical dates
19
- # time = Exchange::Helper.assure_time(opts[:at])
21
+ # #
22
+ # time = helper.assure_time(opts[:at])
20
23
  #
21
- # # call your API (shown here with a helper function that builds your API URL). Like this, your calls will get cached.
24
+ # # Call your API (shown here with a helper function that builds your API URL).
25
+ # # Like this, your calls will get cached.
26
+ # #
22
27
  # Call.new(api_url(time), :at => time) do |result|
23
- #
24
- # # assign the currency conversion base, attention, this is readonly, so don't do self.base =
25
- # @base = result['base']
26
- #
27
- # # assign the rates, this has to be a hash with the following format: {'USD' => 1.23242, 'CHF' => 1.34323}. Attention, this is readonly.
28
- # @rates = result['rates']
29
- #
30
- # # timestamp the api call result. This may come in handy to assure you have the right result. Attention, this is readonly.
31
- # @timestamp = result['timestamp'].to_i
32
- # end
28
+ #
29
+ # # Assign the currency conversion base.
30
+ # # Attention, this is readonly, self.base= won't work
31
+ # #
32
+ # @base = result['base']
33
+ #
34
+ # # assign the rates, this has to be a hash with the following format:
35
+ # # {'USD' => 1.23242, 'CHF' => 1.34323}.
36
+ # #
37
+ # # Attention, this is readonly, self.rates= won't work
38
+ # #
39
+ # @rates = result['rates']
40
+ #
41
+ # # Timestamp the api call result. This may come in handy to assure you have
42
+ # # the right result.
43
+ # #
44
+ # # Attention, this is readonly, self.timestamp= won't work
45
+ # #
46
+ # @timestamp = result['timestamp'].to_i
33
47
  # end
34
48
  #
35
49
  # private
@@ -41,8 +55,11 @@ module Exchange
41
55
  # end
42
56
  # end
43
57
  # end
58
+ #
44
59
  # # Now, you can configure your API in the configuration. The Symbol will get camelcased and constantized
60
+ # #
45
61
  # Exchange::Configuration.api.subclass = :my_custom
62
+ #
46
63
  # # Have fun, and don't forget to write tests.
47
64
  #
48
65
  module ExternalAPI
@@ -69,6 +86,28 @@ module Exchange
69
86
  #
70
87
  attr_reader :rates
71
88
 
89
+ # @attr_reader
90
+ # @return [Exchange::Cache] The cache subclass
91
+ attr_reader :cache
92
+
93
+ # @attr_reader
94
+ # @return [Exchange::API] The api subclass
95
+ attr_reader :api
96
+
97
+ # @attr_reader
98
+ # @return [Exchange::Helper] The Exchange Helper
99
+ attr_reader :helper
100
+
101
+ # Initialize with a convenience accessor for the Cache and the api subclass
102
+ # @param [Any] args The args to initialize with
103
+ #
104
+ def initialize *args
105
+ @cache = Exchange.configuration.cache.subclass
106
+ @api = Exchange.configuration.api.subclass
107
+ @helper = Exchange::Helper.instance
108
+
109
+ super *args
110
+ end
72
111
 
73
112
  # Delivers an exchange rate from one currency to another with the option of getting a historical exchange rate. This rate
74
113
  # has to be multiplied with the amount of the currency which you define in from
@@ -82,13 +121,17 @@ module Exchange
82
121
  # #=> 1.232231231
83
122
  #
84
123
  def rate(from, to, opts={})
85
- rate = Exchange.configuration.cache.subclass.cached(Exchange.configuration.api.subclass, opts.merge(:key_for => [from, to], :plain => true)) do
124
+ rate = cache.cached(api, opts.merge(:key_for => [from, to], :plain => true)) do
86
125
  update(opts)
126
+
87
127
  rate_from = self.rates[to.to_s.upcase]
88
128
  rate_to = self.rates[from.to_s.upcase]
89
- raise NoRateError.new("No rates where found for #{from} to #{to} #{'at ' + opts[:at].to_s if opts[:at]}") unless rate_from && rate_to
129
+
130
+ test_for_rates_and_raise_if_nil rate_from, rate_to, opts[:at]
131
+
90
132
  rate_from / rate_to
91
133
  end
134
+
92
135
  BigDecimal.new(rate.to_s)
93
136
  end
94
137
 
@@ -107,6 +150,23 @@ module Exchange
107
150
  amount * rate(from, to, opts)
108
151
  end
109
152
 
153
+ # Converts an array to a hash
154
+ # @param [Array] array The array to convert
155
+ # @return [Hash] The hash out of the array
156
+ #
157
+ def to_hash! array
158
+ Hash[*array]
159
+ end
160
+
161
+ # Test for a error to be thrown when no rates are present
162
+ # @param [String] rate_from The rate from which should be converted
163
+ # @param [String] rate_to The rate to which should be converted
164
+ # @param [Time] time The time at which should be converted
165
+ # @raise [NoRateError] An error indicating that there is no rate present when there is no rate present
166
+ #
167
+ def test_for_rates_and_raise_if_nil rate_from, rate_to, time=nil
168
+ raise NoRateError.new("No rates where found for #{from} to #{to} #{'at ' + time.to_s if time}") unless rate_from && rate_to
169
+ end
110
170
  end
111
171
  end
112
172
  end
@@ -30,14 +30,16 @@ module Exchange
30
30
  # result = Exchange::ExternalAPI::Call.new('http://yourapiurl.com', :format => :xml)
31
31
  # # Do something with that result
32
32
  #
33
- def initialize url, options={}, &block
33
+ def initialize url, options={}, &block
34
34
  Exchange::GemLoader.new('nokogiri').try_load if options[:format] == :xml
35
35
 
36
- result = Exchange.configuration.cache.subclass.cached(options[:api] || Exchange.configuration.api.subclass, options) do
37
- load_url(url, options[:retries] || Exchange.configuration.api.retries, options[:retry_with])
36
+ api_config = Exchange.configuration.api
37
+
38
+ result = Exchange.configuration.cache.subclass.cached(options[:api] || api_config.subclass, options) do
39
+ load_url(url, options[:retries] || api_config.retries, options[:retry_with])
38
40
  end
39
41
 
40
- parsed = options[:format] == :xml ? Nokogiri.parse(result) : ::JSON.load(result)
42
+ parsed = options[:format] == :xml ? Nokogiri::XML.parse(result.sub("\n", '')) : ::JSON.load(result)
41
43
 
42
44
  return parsed unless block_given?
43
45
  yield parsed
@@ -49,6 +51,7 @@ module Exchange
49
51
  # @param [String] url The url to be loaded
50
52
  # @param [Integer] retries The number of retries to do if the API Call should fail with a HTTP Error
51
53
  # @param [Array] retry_with An array of urls to retry the API call with if the call to the original URL should fail. These values will be shifted until a call succeeds or the number of maximum retries is reached
54
+ # @todo install a timeout for slow requests, but respect when loading large files
52
55
  #
53
56
  def load_url(url, retries, retry_with)
54
57
  begin
@@ -24,17 +24,27 @@ module Exchange
24
24
  # Exchange::ExternalAPI::CurrencyBot.new.update(:at => Time.gm(3,2,2010))
25
25
  #
26
26
  def update(opts={})
27
- time = Exchange::Helper.assure_time(opts[:at])
27
+ time = helper.assure_time(opts[:at])
28
28
 
29
29
  Call.new(api_url(time), :at => time) do |result|
30
30
  @base = result['base']
31
- @rates = Hash[*result['rates'].keys.zip(result['rates'].values.map{|v| BigDecimal.new(v.to_s) }).flatten]
31
+ @rates = extract_rates(result)
32
32
  @timestamp = result['timestamp'].to_i
33
33
  end
34
34
  end
35
35
 
36
36
  private
37
37
 
38
+ # Helper method to extract rates from the api call result
39
+ # @param [JSON] parsed The parsed result
40
+ # @return [Hash] A hash with rates
41
+ # @since 0.7
42
+ # @version 0.7
43
+ #
44
+ def extract_rates parsed
45
+ to_hash! parsed['rates'].keys.zip(parsed['rates'].values.map{|v| BigDecimal.new(v.to_s) }).flatten
46
+ end
47
+
38
48
  # A helper function to build an api url for either a specific time or the latest available rates
39
49
  # @param [Time] time The time to build the api url for
40
50
  # @return [String] an api url for the time specified
@@ -4,7 +4,7 @@ module Exchange
4
4
  # The ECB class, handling communication with the European Central Bank XML File API
5
5
  # You can find further information on the European Central Bank XML API API here: http://www.ecb.int/stats/exchange/eurofxref/html/index.en.html
6
6
  # @author Beat Richartz
7
- # @version 0.3
7
+ # @version 0.7
8
8
  # @since 0.3
9
9
  #
10
10
  class Ecb < XML
@@ -28,22 +28,23 @@ module Exchange
28
28
  # @example Update the ecb API to use the file of March 2, 2010
29
29
  # Exchange::ExternalAPI::Ecb.new.update(:at => Time.gm(3,2,2010))
30
30
  #
31
+ # @since 0.1
32
+ # @version 0.7
33
+ #
31
34
  def update(opts={})
32
- time = Exchange::Helper.assure_time(opts[:at], :default => :now)
33
- times = Exchange.configuration.api.retries.times.map{ |i| time - 86400 * (i+1) }
35
+ time = helper.assure_time(opts[:at], :default => :now)
36
+ times = map_retry_times time
34
37
 
35
38
  # Since the Ecb File retrieved can be very large (> 5MB for the history file) and parsing takes a fair amount of time,
36
39
  # caching is doubled on this API
37
40
  #
38
- Exchange.configuration.cache.subclass.cached(self.class, :at => time) do
41
+ cache.cached(self.class, :at => time) do
39
42
  Call.new(api_url(time), call_opts(time)) do |result|
40
43
  t = time
41
44
 
42
45
  # Weekends do not have rates present
43
46
  #
44
- while (r = result.css("Cube[time=\"#{t.strftime("%Y-%m-%d")}\"]")).empty? && !times.empty?
45
- t = times.shift
46
- end
47
+ t = times.shift while (r = find_rate!(result, t)).empty? && !times.empty?
47
48
 
48
49
  @callresult = r.to_s
49
50
  end
@@ -52,7 +53,7 @@ module Exchange
52
53
  parsed = Nokogiri.parse(self.callresult)
53
54
 
54
55
  @base = 'EUR' # We just have to assume, since it's the ECB
55
- @rates = Hash[*(['EUR', BigDecimal.new("1")] + parsed.children.children.map {|c| c.attributes.values.map{|v| v.value.match(/\d/) ? BigDecimal.new(v.value) : v.value }.sort_by(&:to_s).reverse unless c.attributes.values.empty? }.compact.flatten)]
56
+ @rates = extract_rates(parsed.children.children)
56
57
  @timestamp = time.to_i
57
58
  end
58
59
 
@@ -72,9 +73,62 @@ module Exchange
72
73
  ].join('/')
73
74
  end
74
75
 
76
+ # A helper method to find rates from the callresult given a certain time
77
+ # ECB packs the rates in «Cubes», so we try to find the cube appropriate to the time
78
+ # @param [Nokogiri::XML] parsed The parsed callresult
79
+ # @param [Time] time The time to parse for
80
+ # @return [Nokogiri::XML, NilClass] the rate, hopefully
81
+ # @since 0.7
82
+ # @version 0.7
83
+ #
84
+ def find_rate! parsed, time
85
+ parsed.css("Cube[time=\"#{time.strftime("%Y-%m-%d")}\"]")
86
+ end
87
+
88
+ # A helper method to extract rates from the callresult
89
+ # @param [Nokogiri::XML] parsed the parsed api data
90
+ # @return [Hash] a hash with rates
91
+ # @since 0.7
92
+ # @version 0.7
93
+ #
94
+ def extract_rates parsed
95
+ rate_array = parsed.map { |c|
96
+ map_to_currency_or_rate c
97
+ }.compact.flatten
98
+
99
+ to_hash!(['EUR', BigDecimal.new("1")] + rate_array)
100
+ end
101
+
102
+ # a helper method to map a key value pair to either currency or rate
103
+ # @param [Nokogiri::XML] xml a parsed xml part of the document
104
+ # @return [Array] An array with the following structure [currency, value, currency, value]
105
+ # @since 0.7
106
+ # @version 0.7
107
+ #
108
+ def map_to_currency_or_rate xml
109
+ unless (values = xml.attributes.values).empty?
110
+ values.map { |v|
111
+ val = v.value
112
+ val.match(/\d+/) ? BigDecimal.new(val) : val
113
+ }.sort_by(&:to_s).reverse
114
+ end
115
+ end
116
+
117
+ # Helper method to map retry times
118
+ # @param [Time] time The time to start with
119
+ # @return [Array] An array of times to retry api operation with
120
+ # @since 0.7
121
+ # @version 0.7
122
+ #
123
+ def map_retry_times time
124
+ Exchange.configuration.api.retries.times.map{ |i| time - 86400 * (i+1) }
125
+ end
126
+
75
127
  # a wrapper for the call options, since the cache period is quite complex
76
128
  # @param [Time] time The date of the exchange rate
77
129
  # @return [Hash] a hash with the call options
130
+ # @since 0.6
131
+ # @version 0.6
78
132
  #
79
133
  def call_opts time
80
134
  {:format => :xml, :at => time, :cache => :file, :cache_period => time >= Time.now - 90 * 86400 ? :daily : :monthly}
@@ -1,22 +1,12 @@
1
1
  module Exchange
2
2
  module ExternalAPI
3
3
 
4
- # The json base class takes care of JSON apis. It assumes you would want to use json as a parser and preloads the gem
5
- # Also, this may serve as a base for some operations which might be common to the json apis
4
+ # The json base class takes care of JSON apis.
5
+ # This may serve as a base for some operations which might be common to the json apis
6
6
  # @author Beat Richartz
7
7
  # @version 0.6
8
8
  # @since 0.6
9
9
  #
10
- class JSON < Base
11
-
12
- # Initializer, essentially takes the arguments passed to initialization, loads the json gem on the way
13
- # and passes the arguments to the api base
14
- #
15
- def initialize *args
16
- Exchange::GemLoader.new('json').try_load unless defined?(::JSON)
17
- super *args
18
- end
19
-
20
- end
10
+ JSON = Class.new Base
21
11
  end
22
12
  end
@@ -4,7 +4,7 @@ module Exchange
4
4
  # The XavierMedia API class, handling communication with the Xavier Media Currency API
5
5
  # You can find further information on the Xaviermedia API here: http://www.xavierforum.com/viewtopic.php?f=5&t=10979&sid=671a685edbfa5dbec219fbc6793d5057
6
6
  # @author Beat Richartz
7
- # @version 0.1
7
+ # @version 0.7
8
8
  # @since 0.1
9
9
  #
10
10
  class XavierMedia < XML
@@ -16,21 +16,20 @@ module Exchange
16
16
 
17
17
  # Updates the rates by getting the information from Xaviermedia API for today or a defined historical date
18
18
  # The call gets cached for a maximum of 24 hours.
19
- # @version 0.3
19
+ # @version 0.7
20
20
  # @param [Hash] opts Options to define for the API Call
21
21
  # @option opts [Time, String] :at a historical date to get the exchange rates for
22
22
  # @example Update the currency bot API to use the file of March 2, 2010
23
23
  # Exchange::ExternalAPI::XavierMedia.new.update(:at => Time.gm(3,2,2010))
24
24
  #
25
25
  def update(opts={})
26
- time = Exchange::Helper.assure_time(opts[:at], :default => :now)
26
+ time = helper.assure_time(opts[:at], :default => :now)
27
27
  api_url = api_url(time)
28
- retry_urls = Exchange.configuration.api.retries.times.map{ |i| api_url(time - 86400 * (i+1)) }
29
28
 
30
- Call.new(api_url, :format => :xml, :at => time, :retry_with => retry_urls) do |result|
31
- @base = result.css('basecurrency').children[0].to_s
32
- @rates = Hash[*result.css('fx currency_code').children.map(&:to_s).zip(result.css('fx rate').children.map{|c| BigDecimal.new(c.to_s) }).flatten]
33
- @timestamp = Time.gm(*result.css('fx_date').children[0].to_s.split('-')).to_i
29
+ Call.new(api_url, api_opts(opts.merge(:at => time))) do |result|
30
+ @base = extract_base_currency result
31
+ @rates = extract_rates result
32
+ @timestamp = extract_timestamp result
34
33
  end
35
34
  end
36
35
 
@@ -44,6 +43,50 @@ module Exchange
44
43
  [API_URL, "#{time.strftime("%Y/%m/%d")}.xml"].join('/')
45
44
  end
46
45
 
46
+ # Options for the API call to make
47
+ # @param [Hash] opts The options to generate the call options with
48
+ # @option opts [Time, String] :at a historical date to get the exchange rates for
49
+ # @return [Hash] The options hash for the API call
50
+ # @since 0.6
51
+ # @version 0.6
52
+ #
53
+ def api_opts(opts={})
54
+ retry_urls = Exchange.configuration.api.retries.times.map { |i| api_url(opts[:at] - 86400 * (i+1)) }
55
+
56
+ { :format => :xml, :at => opts[:at], :retry_with => retry_urls }
57
+ end
58
+
59
+ # Extract a timestamp of the callresult
60
+ # @param [Nokogiri::XML] result the callresult
61
+ # @return [Integer] A unix timestamp
62
+ # @since 0.7
63
+ # @version 0.7
64
+ #
65
+ def extract_timestamp(result)
66
+ Time.gm(*result.css('fx_date').children[0].to_s.split('-')).to_i
67
+ end
68
+
69
+ # Extract rates from the callresult
70
+ # @param [Nokogiri::XML] result the callresult
71
+ # @return [Hash] A hash with currency / rate pairs
72
+ # @since 0.7
73
+ # @version 0.7
74
+ #
75
+ def extract_rates(result)
76
+ rates_array = result.css('fx currency_code').children.map(&:to_s).zip(result.css('fx rate').children.map{|c| BigDecimal.new(c.to_s) }).flatten
77
+ to_hash!(rates_array)
78
+ end
79
+
80
+ # Extract the base currency from the callresult
81
+ # @param [Nokogiri::XML] result the callresult
82
+ # @return [String] The base currency for the rates
83
+ # @since 0.7
84
+ # @version 0.7
85
+ #
86
+ def extract_base_currency(result)
87
+ result.css('basecurrency').children[0].to_s
88
+ end
89
+
47
90
  end
48
91
  end
49
92
  end
@@ -13,7 +13,7 @@ module Exchange
13
13
  extend SingleForwardable
14
14
 
15
15
  # A helper function to assure a value is an instance of time
16
- # @param [Time, String, NilClass] The value to be asserted
16
+ # @param [Time, String, NilClass] arg The value to be asserted
17
17
  # @param [Hash] opts Options for assertion
18
18
  # @option opts [Symbol] :default a method that can be sent to Time if the argument is nil (:now for example)
19
19
  #
@@ -1,5 +1,6 @@
1
1
  require 'singleton'
2
2
  require 'forwardable'
3
+ require 'yaml'
3
4
 
4
5
  module Exchange
5
6
 
@@ -68,26 +68,52 @@ describe "Exchange::CacheDalli::Client" do
68
68
  end
69
69
  context "when no cached result exists" do
70
70
  let(:client) { mock('memcached') }
71
- before(:each) do
72
- subject.should_receive(:key).with('API_CLASS', {}).twice.and_return('KEY')
73
- client.should_receive(:get).with('KEY').and_return(nil)
74
- end
75
- context "with daily cache" do
76
- it "should call the block and set and return the result" do
77
- client.should_receive(:set).with('KEY', "{\"RESULT\":\"YAY\"}", 86400).once
78
- subject.cached('API_CLASS') { {'RESULT' => 'YAY'} }.should == {'RESULT' => 'YAY'}
71
+ context "when returning nil" do
72
+ before(:each) do
73
+ subject.should_receive(:key).with('API_CLASS', {}).twice.and_return('KEY')
74
+ client.should_receive(:get).with('KEY').and_return(nil)
75
+ end
76
+ context "with daily cache" do
77
+ it "should call the block and set and return the result" do
78
+ client.should_receive(:set).with('KEY', "{\"RESULT\":\"YAY\"}", 86400).once
79
+ subject.cached('API_CLASS') { {'RESULT' => 'YAY'} }.should == {'RESULT' => 'YAY'}
80
+ end
81
+ end
82
+ context "with hourly cache" do
83
+ before(:each) do
84
+ Exchange.configuration.cache.expire = :hourly
85
+ end
86
+ after(:each) do
87
+ Exchange.configuration.cache.expire = :daily
88
+ end
89
+ it "should call the block and set and return the result" do
90
+ client.should_receive(:set).with('KEY', "{\"RESULT\":\"YAY\"}", 3600).once
91
+ subject.cached('API_CLASS') { {'RESULT' => 'YAY'} }.should == {'RESULT' => 'YAY'}
92
+ end
79
93
  end
80
94
  end
81
- context "with hourly cache" do
95
+ context "when returning an empty string" do
82
96
  before(:each) do
83
- Exchange.configuration.cache.expire = :hourly
97
+ subject.should_receive(:key).with('API_CLASS', {}).twice.and_return('KEY')
98
+ client.should_receive(:get).with('KEY').and_return('')
84
99
  end
85
- after(:each) do
86
- Exchange.configuration.cache.expire = :daily
100
+ context "with daily cache" do
101
+ it "should call the block and set and return the result" do
102
+ client.should_receive(:set).with('KEY', "{\"RESULT\":\"YAY\"}", 86400).once
103
+ subject.cached('API_CLASS') { {'RESULT' => 'YAY'} }.should == {'RESULT' => 'YAY'}
104
+ end
87
105
  end
88
- it "should call the block and set and return the result" do
89
- client.should_receive(:set).with('KEY', "{\"RESULT\":\"YAY\"}", 3600).once
90
- subject.cached('API_CLASS') { {'RESULT' => 'YAY'} }.should == {'RESULT' => 'YAY'}
106
+ context "with hourly cache" do
107
+ before(:each) do
108
+ Exchange.configuration.cache.expire = :hourly
109
+ end
110
+ after(:each) do
111
+ Exchange.configuration.cache.expire = :daily
112
+ end
113
+ it "should call the block and set and return the result" do
114
+ client.should_receive(:set).with('KEY', "{\"RESULT\":\"YAY\"}", 3600).once
115
+ subject.cached('API_CLASS') { {'RESULT' => 'YAY'} }.should == {'RESULT' => 'YAY'}
116
+ end
91
117
  end
92
118
  end
93
119
  end