exchange 0.2.6

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. data/.document +5 -0
  2. data/.rspec +2 -0
  3. data/.travis.yml +6 -0
  4. data/Gemfile +17 -0
  5. data/Gemfile.lock +41 -0
  6. data/LICENSE.txt +20 -0
  7. data/README.rdoc +262 -0
  8. data/Rakefile +46 -0
  9. data/VERSION +1 -0
  10. data/exchange.gemspec +102 -0
  11. data/lib/core_extensions/conversability.rb +32 -0
  12. data/lib/exchange.rb +11 -0
  13. data/lib/exchange/cache.rb +4 -0
  14. data/lib/exchange/cache/base.rb +51 -0
  15. data/lib/exchange/cache/memcached.rb +52 -0
  16. data/lib/exchange/cache/rails.rb +45 -0
  17. data/lib/exchange/cache/redis.rb +54 -0
  18. data/lib/exchange/configuration.rb +72 -0
  19. data/lib/exchange/currency.rb +237 -0
  20. data/lib/exchange/external_api.rb +4 -0
  21. data/lib/exchange/external_api/base.rb +114 -0
  22. data/lib/exchange/external_api/call.rb +76 -0
  23. data/lib/exchange/external_api/currency_bot.rb +47 -0
  24. data/lib/exchange/external_api/xavier_media.rb +47 -0
  25. data/spec/core_extensions/conversability_spec.rb +64 -0
  26. data/spec/exchange/cache/base_spec.rb +29 -0
  27. data/spec/exchange/cache/memcached_spec.rb +72 -0
  28. data/spec/exchange/cache/rails_spec.rb +67 -0
  29. data/spec/exchange/cache/redis_spec.rb +76 -0
  30. data/spec/exchange/configuration_spec.rb +47 -0
  31. data/spec/exchange/currency_spec.rb +219 -0
  32. data/spec/exchange/external_api/base_spec.rb +31 -0
  33. data/spec/exchange/external_api/call_spec.rb +68 -0
  34. data/spec/exchange/external_api/currency_bot_spec.rb +61 -0
  35. data/spec/exchange/external_api/xavier_media_spec.rb +59 -0
  36. data/spec/spec_helper.rb +28 -0
  37. data/spec/support/api_responses/example_historic_json.json +167 -0
  38. data/spec/support/api_responses/example_json_api.json +167 -0
  39. data/spec/support/api_responses/example_xml_api.xml +156 -0
  40. metadata +191 -0
@@ -0,0 +1,4 @@
1
+ require 'exchange/external_api/base'
2
+ require 'exchange/external_api/call'
3
+ require 'exchange/external_api/currency_bot'
4
+ require 'exchange/external_api/xavier_media'
@@ -0,0 +1,114 @@
1
+ module Exchange
2
+ # The external API module. Every class Handling an API has to be placed here and inherit from base. It has to call an api and define
3
+ # a rates hash, an exchange base and a unix timestamp. The call will get cached automatically with the right structure
4
+ # Allows for easy extension with an own api, as shown below
5
+ # @author Beat Richartz
6
+ # @version 0.1
7
+ # @since 0.1
8
+ # @example Easily connect to your custom API by writing an ExternalAPI Class
9
+ # module Exchange
10
+ # module ExternalAPI
11
+ # class MyCustom < Base
12
+ # # Define here which currencies your API can handle
13
+ # CURRENCIES = %W(usd chf)
14
+ #
15
+ # # Every instance of ExternalAPI Class has to have an update function which gets the rates from the API
16
+ # def update(opts={})
17
+ # # assure that you will get a Time object for the historical dates
18
+ # time = assure_time(opts[:at])
19
+ #
20
+ # # call your API (shown here with a helper function that builds your API URL). Like this, your calls will get cached.
21
+ # Call.new(api_url(time), :at => time) do |result|
22
+ #
23
+ # # assign the currency conversion base, attention, this is readonly, so don't do self.base =
24
+ # @base = result['base']
25
+ #
26
+ # # assign the rates, this has to be a hash with the following format: {'USD' => 1.23242, 'CHF' => 1.34323}. Attention, this is readonly.
27
+ # @rates = result['rates']
28
+ #
29
+ # # timestamp the api call result. This may come in handy to assure you have the right result. Attention, this is readonly.
30
+ # @timestamp = result['timestamp'].to_i
31
+ # end
32
+ # end
33
+ #
34
+ # private
35
+ #
36
+ # def api_url(time)
37
+ # # code a helper function that builds your api url for the specified time
38
+ # end
39
+ #
40
+ # end
41
+ # end
42
+ # end
43
+ # # Now, you can configure your API in the configuration. The Symbol will get camelcased and constantized
44
+ # Exchange::Configuration.api = :my_custom
45
+ # # Have fun, and don't forget to write tests.
46
+
47
+ module ExternalAPI
48
+
49
+ # The Base class of all External APIs, handling basic exchange rates and conversion
50
+ # @author Beat Richartz
51
+ # @version 0.1
52
+ # @since 0.1
53
+
54
+ class Base
55
+ # @attr_reader
56
+ # @return [String] The currency which was the base for the rates
57
+ attr_reader :base
58
+
59
+ # @attr_reader
60
+ # @return [Integer] A unix timestamp for the rates, delivered by the API
61
+ attr_reader :timestamp
62
+
63
+ # @attr_reader
64
+ # @return [Hash] A Hash which delivers the exchange rate of every available currency to the base currency
65
+ attr_reader :rates
66
+
67
+
68
+ # Delivers an exchange rate from one currency to another with the option of getting a historical exchange rate. This rate
69
+ # has to be multiplied with the amount of the currency which you define in from
70
+ # @param [String, Symbol] from The currency which should be converted
71
+ # @param [String, Symbol] to The currency which the should be converted to
72
+ # @param [Hash] opts The options to throw at the rate
73
+ # @option opts [Time] :at Define a Time here to get a historical rate
74
+ # @return [Float] The exchange rate for those two currencies
75
+ # @example Get the exchange rate for a conversion from USD to EUR at March 23 2009
76
+ # Exchange::ExternalAPI::Base.new.rate(:usd, :eur, :at => Time.gm(3,23,2009))
77
+ # #=> 1.232231231
78
+ def rate(from, to, opts={})
79
+ update(opts)
80
+ self.rates[to.to_s.upcase] / self.rates[from.to_s.upcase]
81
+ end
82
+
83
+ # Converts an amount of one currency into another
84
+ # @param [Fixed, Float] amount The amount of the currency to be converted
85
+ # @param [String, Symbol] from The currency to be converted from
86
+ # @param [String, Symbol] to The currency which should be converted to
87
+ # @param [Hash] opts Options to throw at the conversion
88
+ # @option opts [Time] :at Define a Time here to convert at a historical rate
89
+ # @return [Float] The amount in the currency converted to, rounded to two decimals
90
+ # @example Convert 23 EUR to CHF at the rate of December 1 2011
91
+ # Exchange::ExternalAPI::Base.new.convert(23, :eur, :chf, :at => Time.gm(12,1,2011))
92
+ # #=> 30.12
93
+ def convert(amount, from, to, opts={})
94
+ (amount.to_f * rate(from, to, opts) * 100).round.to_f / 100
95
+ end
96
+
97
+ protected
98
+
99
+ # A helper function to assure a value is an instance of time
100
+ # @param [Time, String, NilClass] The value to be asserted
101
+ # @param [Hash] opts Options for assertion
102
+ # @option opts [Symbol] :default If the argument is nil, you can define :default as :now to be delivered with Time.now instead of nil
103
+
104
+ def assure_time(arg=nil, opts={})
105
+ if arg
106
+ arg.kind_of?(Time) ? arg : Time.gm(*arg.split('-'))
107
+ elsif opts[:default] == :now
108
+ Time.now
109
+ end
110
+ end
111
+
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,76 @@
1
+ module Exchange
2
+ module ExternalAPI
3
+
4
+ # A class to handle API calls in a standardized way for all APIs
5
+ # @author Beat Richartz
6
+ # @version 0.1
7
+ # @since 0.1
8
+
9
+ class Call
10
+
11
+ # Initialization of the Call class is the call itself. This means that every instance of the class will only exist during the call
12
+ # @param [String] url The url of the API to call
13
+ # @param [Hash] options The options of the API call
14
+ # @option options [Time] :at The time of the historical exchange rate file to get
15
+ # @option options [Integer] :retries The number of retries if the API Call should fail with a HTTP Error
16
+ # @option options [Array] :retry_with an Array of urls to retry the call with (if the API does not have a file for the specified date). These values will be shifted until a call succeeds or the number of maximum retries is reached.
17
+ # @option options [Symbol] :format The format to return / yield the API call result in, defaults to :json
18
+ # @yield [Nokogiri::XML, Hash] The result of the API call, either nokogiri parsed XML or a hash loaded from JSON
19
+ # @return [Nokogiri::XML, Hash] Returns the result of the API call if no block is given, either nokogiri parsed XML or a hash loaded from JSON
20
+ # @example Call an API an yield the result
21
+ # Exchange::ExternalAPI::Call.new('http://yourapiurl.com', :format => :xml) do |result|
22
+ # # Do something with the result here, for example
23
+ # rates = {}
24
+ # result.css('rates').each do |rate|
25
+ # rates.merge! rate.css('currency').children.to_s => rate.css('rate').children.to_s.to_f
26
+ # end
27
+ # end
28
+ # @example Call the API and do something with the result
29
+ # result = Exchange::ExternalAPI::Call.new('http://yourapiurl.com', :format => :xml)
30
+ # # Do something with that result
31
+
32
+ def initialize url, options={}, &block
33
+ if Exchange::Configuration.cache
34
+ result = Exchange::Configuration.cache_class.cached(Exchange::Configuration.api_class, :at => options[:at]) do
35
+ load_url(url, options[:retries] || 5, options[:retry_with])
36
+ end
37
+ else
38
+ result = load_url(url, options[:retries] || 5, options[:retry_with])
39
+ end
40
+
41
+ parsed = options[:format] == :xml ? Nokogiri.parse(result) : JSON.load(result)
42
+
43
+ return parsed unless block_given?
44
+
45
+ yield parsed
46
+ end
47
+
48
+ private
49
+
50
+ # A helper function to load the API URL with
51
+ # @param [String] url The url to be loaded
52
+ # @param [Integer] retries The number of retries to do if the API Call should fail with a HTTP Error
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
+
55
+ def load_url(url, retries, retry_with)
56
+ begin
57
+ result = URI.parse(url).open.read
58
+ rescue SocketError
59
+ raise APIError.new("Calling API #{url} produced a socket error")
60
+ rescue OpenURI::HTTPError
61
+ if retries > 0
62
+ retries -= 1
63
+ url = retry_with.shift if retry_with && !retry_with.empty?
64
+ retry
65
+ else
66
+ raise APIError.new("API #{url} was not reachable")
67
+ end
68
+ end
69
+ end
70
+
71
+ end
72
+
73
+ # The Api Error to throw when an API Call fails
74
+ APIError = Class.new(StandardError)
75
+ end
76
+ end
@@ -0,0 +1,47 @@
1
+ module Exchange
2
+ module ExternalAPI
3
+ # The Currency Bot API class, handling communication with the Open Source Currency bot API
4
+ # You can find further information on the currency bot API here: http://currencybot.github.com/
5
+ # @author Beat Richartz
6
+ # @version 0.1
7
+ # @since 0.1
8
+
9
+ class CurrencyBot < Base
10
+ # The base of the Currency Bot exchange API
11
+ API_URL = 'https://raw.github.com/currencybot/open-exchange-rates/master'
12
+ # The currencies the Currency Bot API can convert
13
+ CURRENCIES = %W(xcd usd sar rub nio lak nok omr amd cdf kpw cny kes zwd khr pln mvr gtq clp inr bzd myr hkd sek cop dkk byr lyd ron dzd bif ars gip bob xof std ngn pgk aed mwk cup gmd zwl tzs cve btn xaf ugx syp mad mnt lsl top shp rsd htg mga mzn lvl fkp bwp hnl eur egp chf ils pyg lbp ang kzt wst gyd thb npr kmf irr uyu srd jpy brl szl mop bmd xpf etb jod idr mdl mro yer bam awg nzd pen vef try sll aoa tnd tjs sgd scr lkr mxn ltl huf djf bsd gnf isk vuv sdg gel fjd dop xdr mur php mmk krw lrd bbd zmk zar vnd uah tmt iqd bgn gbp kgs ttd hrk rwf clf bhd uzs twd crc aud mkd pkr afn nad bdt azn czk sos iep pab qar svc sbd all jmd bnd cad kwd ghs)
14
+
15
+ # Updates the rates by getting the information from Currency Bot for today or a defined historical date
16
+ # The call gets cached for a maximum of 24 hours.
17
+ # @param [Hash] opts Options to define for the API Call
18
+ # @option opts [Time, String] :at a historical date to get the exchange rates for
19
+ # @example Update the currency bot API to use the file of March 2, 2010
20
+ # Exchange::ExternalAPI::CurrencyBot.new.update(:at => Time.gm(3,2,2010))
21
+
22
+ def update(opts={})
23
+ time = assure_time(opts[:at])
24
+
25
+ Call.new(api_url(time), :at => time) do |result|
26
+ @base = result['base']
27
+ @rates = result['rates']
28
+ @timestamp = result['timestamp'].to_i
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ # A helper function to build an api url for either a specific time or the latest available rates
35
+ # @param [Time] time The time to build the api url for
36
+ # @return [String] an api url for the time specified
37
+ # @since 0.1
38
+ # @version 0.2.6
39
+
40
+ def api_url(time=nil)
41
+ today = Time.now
42
+ [API_URL, time && (time.year != today.year || time.yday != today.yday) ? "historical/#{time.strftime("%Y-%m-%d")}.json" : 'latest.json'].join('/')
43
+ end
44
+
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,47 @@
1
+ module Exchange
2
+ module ExternalAPI
3
+
4
+ # The XavierMedia API class, handling communication with the Xavier Media Currency API
5
+ # You can find further information on the Xaviermedia API here: http://www.xavierforum.com/viewtopic.php?f=5&t=10979&sid=671a685edbfa5dbec219fbc6793d5057
6
+ # @author Beat Richartz
7
+ # @version 0.1
8
+ # @since 0.1
9
+
10
+ class XavierMedia < Base
11
+ # The base of the Xaviermedia API URL
12
+ API_URL = "http://api.finance.xaviermedia.com/api"
13
+ # The currencies the Xaviermedia API URL can handle
14
+ CURRENCIES = %W(eur usd jpy gbp cyp czk dkk eek huf ltl mtl pln sek sit skk chf isk nok bgn hrk rol ron rub trl aud cad cny hkd idr krw myr nzd php sgd thb zar)
15
+
16
+ # Updates the rates by getting the information from Xaviermedia API for today or a defined historical date
17
+ # The call gets cached for a maximum of 24 hours.
18
+ # @param [Hash] opts Options to define for the API Call
19
+ # @option opts [Time, String] :at a historical date to get the exchange rates for
20
+ # @example Update the currency bot API to use the file of March 2, 2010
21
+ # Exchange::ExternalAPI::XavierMedia.new.update(:at => Time.gm(3,2,2010))
22
+
23
+ def update(opts={})
24
+ time = assure_time(opts[:at], :default => :now)
25
+ api_url = api_url(time)
26
+ retry_urls = [api_url(time - 86400), api_url(time - 172800), api_url(time - 259200)]
27
+
28
+ Call.new(api_url, :format => :xml, :at => time, :retry_with => retry_urls) do |result|
29
+ @base = result.css('basecurrency').children[0].to_s
30
+ @rates = Hash[*result.css('fx currency_code').children.map(&:to_s).zip(result.css('fx rate').children.map{|c| c.to_s.to_f }).flatten]
31
+ @timestamp = Time.gm(*result.css('fx_date').children[0].to_s.split('-')).to_i
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # A helper function which build a valid api url for the specified time
38
+ # @param [Time] time The exchange rate date for which the URL should be built
39
+ # @return [String] An Xaviermedia API URL for the specified time
40
+
41
+ def api_url(time)
42
+ [API_URL, "#{time.strftime("%Y/%m/%d")}.xml"].join('/')
43
+ end
44
+
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,64 @@
1
+ require 'spec_helper'
2
+
3
+ describe "Exchange::Conversability" do
4
+ before(:all) do
5
+ Exchange::Configuration.cache = false
6
+ end
7
+ after(:all) do
8
+ Exchange::Configuration.cache = :memcached
9
+ end
10
+ context "with a fixnum" do
11
+ it "should allow to convert to a currency" do
12
+ 3.eur.should be_kind_of Exchange::Currency
13
+ 3.eur.value.should == 3
14
+ end
15
+ it "should allow to convert to a curreny with a negative number" do
16
+ -3.eur.should be_kind_of Exchange::Currency
17
+ -3.eur.value.should == -3
18
+ end
19
+ it "should allow to do full conversions" do
20
+ mock_api("https://raw.github.com/currencybot/open-exchange-rates/master/latest.json", fixture('api_responses/example_json_api.json'), 3)
21
+ 3.eur.to_chf.should be_kind_of Exchange::Currency
22
+ 3.eur.to_chf.value.should == 3.62
23
+ 3.eur.to_chf.currency.should == 'chf'
24
+ end
25
+ it "should allow to do full conversions with negative numbers" do
26
+ mock_api("https://raw.github.com/currencybot/open-exchange-rates/master/latest.json", fixture('api_responses/example_json_api.json'), 3)
27
+ -3.eur.to_chf.should be_kind_of Exchange::Currency
28
+ -3.eur.to_chf.value.should == -3.62
29
+ -3.eur.to_chf.currency.should == 'chf'
30
+ end
31
+ it "should allow to define a historic time in which the currency should be interpreted" do
32
+ 3.chf(:at => Time.gm(2010,1,1)).time.yday.should == 1
33
+ 3.chf(:at => Time.gm(2010,1,1)).time.year.should == 2010
34
+ 3.chf(:at => '2010-01-01').time.year.should == 2010
35
+ end
36
+ end
37
+ context "with a float" do
38
+ it "should allow to convert to a currency" do
39
+ 3.25.eur.should be_kind_of Exchange::Currency
40
+ 3.25.eur.value.should == 3.25
41
+ end
42
+ it "should allow to convert to a curreny with a negative number" do
43
+ -3.25.eur.should be_kind_of Exchange::Currency
44
+ -3.25.eur.value.should == -3.25
45
+ end
46
+ it "should allow to do full conversions" do
47
+ mock_api("https://raw.github.com/currencybot/open-exchange-rates/master/latest.json", fixture('api_responses/example_json_api.json'), 3)
48
+ 3.25.eur.to_chf.should be_kind_of Exchange::Currency
49
+ 3.25.eur.to_chf.value.should == 3.92
50
+ 3.25.eur.to_chf.currency.should == 'chf'
51
+ end
52
+ it "should allow to do full conversions with negative numbers" do
53
+ mock_api("https://raw.github.com/currencybot/open-exchange-rates/master/latest.json", fixture('api_responses/example_json_api.json'), 3)
54
+ -3.25.eur.to_chf.should be_kind_of Exchange::Currency
55
+ -3.25.eur.to_chf.value.should == -3.92
56
+ -3.25.eur.to_chf.currency.should == 'chf'
57
+ end
58
+ it "should allow to define a historic time in which the currency should be interpreted" do
59
+ 3.25.chf(:at => Time.gm(2010,1,1)).time.yday.should == 1
60
+ 3.25.chf(:at => Time.gm(2010,1,1)).time.year.should == 2010
61
+ 3.25.chf(:at => '2010-01-01').time.year.should == 2010
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,29 @@
1
+ require 'spec_helper'
2
+
3
+ describe "Exchange::Cache::Base" do
4
+ subject { Exchange::Cache::Base }
5
+ describe "key generation" do
6
+ before(:each) do
7
+ time = Time.gm 2012, 03, 01, 23, 23, 23
8
+ Time.stub! :now => time
9
+ end
10
+ context "with a daily cache" do
11
+ it "should build a timestamped key with the class given, the yearday and the year" do
12
+ Exchange::Cache::Base.send(:key, Exchange::ExternalAPI::XavierMedia).should == 'Exchange_ExternalAPI_XavierMedia_2012_61'
13
+ Exchange::Cache::Base.send(:key, Exchange::ExternalAPI::CurrencyBot).should == 'Exchange_ExternalAPI_CurrencyBot_2012_61'
14
+ end
15
+ end
16
+ context "with an hourly cache" do
17
+ before(:each) do
18
+ Exchange::Configuration.update = :hourly
19
+ end
20
+ after(:each) do
21
+ Exchange::Configuration.update = :daily
22
+ end
23
+ it "should build a timestamped key with the class given, the yearday, the year and the hour" do
24
+ Exchange::Cache::Base.send(:key, Exchange::ExternalAPI::XavierMedia).should == 'Exchange_ExternalAPI_XavierMedia_2012_61_23'
25
+ Exchange::Cache::Base.send(:key, Exchange::ExternalAPI::CurrencyBot).should == 'Exchange_ExternalAPI_CurrencyBot_2012_61_23'
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,72 @@
1
+ require 'spec_helper'
2
+
3
+ describe "Exchange::Cache::Memcached" do
4
+ subject { Exchange::Cache::Memcached }
5
+ before(:each) do
6
+ Exchange::Configuration.define do |c|
7
+ c.cache_host = 'HOST'
8
+ c.cache_port = 'PORT'
9
+ end
10
+ end
11
+ after(:each) do
12
+ Exchange::Configuration.define do |c|
13
+ c.cache_host = 'localhost'
14
+ c.cache_port = 11211
15
+ end
16
+ end
17
+ describe "client" do
18
+ let(:client) { mock('memcached') }
19
+ after(:each) do
20
+ subject.send(:remove_class_variable, "@@client")
21
+ end
22
+ it "should set up a client on the specified host and port for the cache" do
23
+ ::Memcached.should_receive(:new).with("HOST:PORT").and_return(client)
24
+ subject.client.should == client
25
+ end
26
+ end
27
+ describe "cached" do
28
+ context "when a cached result exists" do
29
+ let(:client) { mock('memcached') }
30
+ before(:each) do
31
+ subject.should_receive(:key).with('API_CLASS', nil).and_return('KEY')
32
+ ::Memcached.should_receive(:new).with("HOST:PORT").and_return(client)
33
+ client.should_receive(:get).with('KEY').and_return "{\"RESULT\":\"YAY\"}"
34
+ end
35
+ after(:each) do
36
+ subject.send(:remove_class_variable, "@@client")
37
+ end
38
+ it "should return the JSON loaded result" do
39
+ subject.cached('API_CLASS') { 'something' }.should == {'RESULT' => 'YAY'}
40
+ end
41
+ end
42
+ context "when no cached result exists" do
43
+ let(:client) { mock('memcached') }
44
+ before(:each) do
45
+ subject.should_receive(:key).with('API_CLASS', nil).twice.and_return('KEY')
46
+ ::Memcached.should_receive(:new).with("HOST:PORT").and_return(client)
47
+ client.should_receive(:get).with('KEY').and_raise(::Memcached::NotFound)
48
+ end
49
+ after(:each) do
50
+ subject.send(:remove_class_variable, "@@client")
51
+ end
52
+ context "with daily cache" do
53
+ it "should call the block and set and return the result" do
54
+ client.should_receive(:set).with('KEY', "{\"RESULT\":\"YAY\"}", 86400).once
55
+ subject.cached('API_CLASS') { {'RESULT' => 'YAY'} }.should == {'RESULT' => 'YAY'}
56
+ end
57
+ end
58
+ context "with hourly cache" do
59
+ before(:each) do
60
+ Exchange::Configuration.update = :hourly
61
+ end
62
+ after(:each) do
63
+ Exchange::Configuration.update = :daily
64
+ end
65
+ it "should call the block and set and return the result" do
66
+ client.should_receive(:set).with('KEY', "{\"RESULT\":\"YAY\"}", 3600).once
67
+ subject.cached('API_CLASS') { {'RESULT' => 'YAY'} }.should == {'RESULT' => 'YAY'}
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end