exchange 0.6.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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