diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..320a3ba --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,44 @@ +version: 2 +jobs: + build: + docker: + - image: circleci/ruby:2.4.2 + environment: + BUNDLE_JOBS: 3 + BUNDLE_RETRY: 3 + BUNDLE_PATH: vendor/bundle + steps: + - checkout + + # Which version of bundler? + - run: + name: Which bundler? + command: bundle -v + + # Restore bundle cache + - restore_cache: + keys: + - russian_central_bank-{{ checksum "russian_central_bank.gemspec" }} + - russian_central_bank + + - run: + name: Bundle Install + command: bundle check || bundle install + + # Store bundle cache + - save_cache: + key: russian_central_bank-{{ checksum "russian_central_bank.gemspec" }} + paths: + - vendor/bundle + + # Run rspec + - run: + name: Run rspec + command: | + gem install rspec && \ + bundle exec rspec --format progress \ + $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) + + # Save test results for timing analysis + - store_test_results: + path: test_results diff --git a/.gitignore b/.gitignore index a955cfa..7f761a0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,13 +4,8 @@ .config .yardoc Gemfile.lock -InstalledFiles -_yardoc coverage doc/ -lib/bundler/man -pkg -rdoc spec/reports test/tmp test/version_tmp diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..1e83dd2 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,8 @@ +Metrics/BlockLength: + Enabled: true + Exclude: + - spec/**/* + +Metrics/LineLength: + Enabled: true + Max: 120 diff --git a/README.md b/README.md index 7ab65ee..ffdb99a 100644 --- a/README.md +++ b/README.md @@ -19,19 +19,20 @@ Or install it yourself as: $ gem install russian_central_bank -NOTE: use 0.x version of `russian_central_bank` for > 6.0 `money` versions +NOTE: use 0.x version of `russian_central_bank` for `money` versions < 6.0 -##Dependencies +## Dependencies -* [savon](http://savonrb.com/) +* [httparty](https://github.com/jnunemaker/httparty) * [money](https://github.com/RubyMoney/money) ## Usage -Regular usage +### Regular usage require 'russian_central_bank' + Money.locale_backend = :currency bank = Money::Bank::RussianCentralBank.new Money.default_bank = bank @@ -39,14 +40,17 @@ Regular usage # Load today's rates bank.update_rates - # Exchange 100 USD to RUB - Money.new(1000, "USD").exchange_to('RUB') + # Exchange 1000 USD to RUB + Money.new(1000_00, "USD").exchange_to('RUB').format # => 64.592,50 ₽ -Specific date rates + # Use indirect exchange rates, USD -> RUB -> EUR + Money.new(1000_00, "USD").exchange_to('EUR').format # => €888,26 + +### Specific date rates # Specify rates date - bank.update_rates(Date.today - 3000) - Money.new(1000, "USD").exchange_to('RUB') # makes you feel better + bank.update_rates(Date.new(2010, 12, 31)) + Money.new(1000_00, "USD").exchange_to('RUB').format # => 30.476,90 ₽ # Check last rates update bank.rates_updated_at @@ -54,7 +58,7 @@ Specific date rates # Check on which date rates were updated bank.rates_updated_on -Autoupdate +### Autoupdate # Use ttl attribute to enable rates autoupdate bank.ttl = 1.day @@ -64,14 +68,14 @@ Autoupdate ### Safe rates fetch -There are some cases, when the `cbr.ru` returns HTTP 302. +There are some cases, when the `cbr.ru` doesn't return HTTP 200. To avoid issues in production, you use fallback: ```ruby bank = Money::Bank::RussianCentralBank.new begin bank.update_rates -rescue Money::Bank::RussianCentralBank::FetchError => e +rescue Money::Bank::RussianCentralBankFetcher::FetchError => e Rails.logger.info "CBR failed: #{e.response}" ## fallback diff --git a/lib/money/bank/russian_central_bank.rb b/lib/money/bank/russian_central_bank.rb new file mode 100644 index 0000000..bac2aa6 --- /dev/null +++ b/lib/money/bank/russian_central_bank.rb @@ -0,0 +1,85 @@ +require 'money' + +class Money + module Bank + class RussianCentralBank < Money::Bank::VariableExchange + attr_reader :rates_updated_at, :rates_updated_on, :rates_expired_at, :ttl + + def flush_rates + @store = Money::RatesStore::Memory.new + end + + def update_rates(date = Date.today) + store.transaction do + update_parsed_rates(exchange_rates(date)) + @rates_updated_at = Time.now + @rates_updated_on = date + update_expired_at + store.send(:index) + end + end + + def add_rate(from, to, rate) + super(from, to, rate) + super(to, from, 1.0 / rate) + end + + def get_rate(from, to) + update_rates if rates_expired? + super || indirect_rate(from, to) + end + + def ttl=(value) + @ttl = value + update_expired_at + @ttl + end + + def rates_expired? + rates_expired_at && rates_expired_at <= Time.now + end + + private + + def fetcher + @fetcher ||= RussianCentralBankFetcher.new + end + + def exchange_rates(date) + fetcher.perform(date) + end + + def update_expired_at + @rates_expired_at = if ttl + @rates_updated_at ? @rates_updated_at + ttl : Time.now + else + nil + end + end + + def indirect_rate(from, to) + get_rate('RUB', to) / get_rate('RUB', from) + end + + def local_currencies + @local_currencies ||= Money::Currency.table.map { |currency| currency.last[:iso_code] } + end + + def update_parsed_rates(rates) + add_rate('RUB', 'RUB', 1) + rates.each do |rate| + begin + if local_currencies.include?(rate[:code]) + add_rate( + 'RUB', + rate[:code], + 1 / (rate[:value] / rate[:nominal]) + ) + end + rescue Money::Currency::UnknownCurrency + end + end + end + end + end +end diff --git a/lib/money/bank/russian_central_bank_fetcher.rb b/lib/money/bank/russian_central_bank_fetcher.rb new file mode 100644 index 0000000..8790233 --- /dev/null +++ b/lib/money/bank/russian_central_bank_fetcher.rb @@ -0,0 +1,49 @@ +require 'httparty' + +class Money + module Bank + class RussianCentralBankFetcher + DAILY_RATES_URL = 'http://www.cbr.ru/scripts/XML_daily.asp'.freeze + + class FetchError < StandardError + attr_reader :response + + def initialize(message, response = nil) + super(message) + @response = response + end + end + + def perform(date = Date.today) + response = HTTParty.get(rates_url(date)) + unless response.success? + raise_fetch_error("cbr.ru respond with #{response.code}", response) + end + extract_rates(response.parsed_response) + rescue HTTParty::Error => e + raise_fetch_error(e.message) + end + + private + + def raise_fetch_error(message, response = nil) + raise FetchError.new(message, response) + end + + def rates_url(date) + "#{DAILY_RATES_URL}?date_req=#{date.strftime('%d/%m/%Y')}" + end + + def extract_rates(parsed_response) + rates_arr = parsed_response['ValCurs']['Valute'] + rates_arr.map do |rate| + { + code: rate['CharCode'], + nominal: rate['Nominal'].to_i, + value: rate['Value'].tr(',', '.').to_f + } + end + end + end + end +end diff --git a/lib/russian_central_bank.rb b/lib/russian_central_bank.rb index f8d046c..b0dce77 100644 --- a/lib/russian_central_bank.rb +++ b/lib/russian_central_bank.rb @@ -1,93 +1,2 @@ -require 'money' -require 'savon' - -class Money - module Bank - class RussianCentralBank < Money::Bank::VariableExchange - class FetchError < StandardError - attr_reader :response - - def initialize(message, response=nil) - super(message) - @response = response - end - end - - CBR_SERVICE_URL = 'http://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx?WSDL' - - attr_reader :rates_updated_at, :rates_updated_on, :ttl, :rates_expired_at - - def flush_rates - @store = Money::RatesStore::Memory.new - end - - def update_rates(date = Date.today) - store.transaction do - update_parsed_rates(exchange_rates(date)) - @rates_updated_at = Time.now - @rates_updated_on = date - update_expired_at - store.send(:index) - end - end - - def add_rate(from, to, rate) - super(from, to, rate) - super(to, from, 1.0 / rate) - end - - def get_rate(from, to) - update_rates if rates_expired? - super || indirect_rate(from, to) - end - - def ttl=(value) - @ttl = value - update_expired_at - @ttl - end - - def rates_expired? - rates_expired_at && rates_expired_at <= Time.now - end - - private - - def update_expired_at - @rates_expired_at = if ttl - @rates_updated_at ? @rates_updated_at + ttl : Time.now - else - nil - end - end - - def indirect_rate(from, to) - from_base_rate = get_rate('RUB', from) - to_base_rate = get_rate('RUB', to) - to_base_rate / from_base_rate - end - - def exchange_rates(date = Date.today) - client = Savon::Client.new wsdl: CBR_SERVICE_URL, log: false, log_level: :error, - follow_redirects: true - response = client.call(:get_curs_on_date, message: { 'On_date' => date.strftime('%Y-%m-%dT%H:%M:%S') }) - response.body[:get_curs_on_date_response][:get_curs_on_date_result][:diffgram][:valute_data][:valute_curs_on_date] - rescue Wasabi::Resolver::HTTPError => e - raise FetchError.new(e.message, e.response) - end - - def update_parsed_rates(rates) - local_currencies = Money::Currency.table.map { |currency| currency.last[:iso_code] } - add_rate('RUB', 'RUB', 1) - rates.each do |rate| - begin - if local_currencies.include? rate[:vch_code] - add_rate('RUB', rate[:vch_code], 1/ (rate[:vcurs].to_f / rate[:vnom].to_i)) - end - rescue Money::Currency::UnknownCurrency - end - end - end - end - end -end +require 'money/bank/russian_central_bank_fetcher' +require 'money/bank/russian_central_bank' diff --git a/russian_central_bank.gemspec b/russian_central_bank.gemspec index 88468cf..0cf138b 100644 --- a/russian_central_bank.gemspec +++ b/russian_central_bank.gemspec @@ -4,7 +4,7 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) Gem::Specification.new do |spec| spec.name = 'russian_central_bank' - spec.version = '1.0.1' + spec.version = '1.1.0' spec.authors = ['Ramil Mustafin'] spec.email = ['rommel.rmm@gmail.com'] spec.description = 'RussianCentralBank extends Money::Bank::VariableExchange and gives you access to the Central Bank of Russia currency exchange rates.' @@ -17,10 +17,13 @@ Gem::Specification.new do |spec| spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ['lib'] - spec.add_development_dependency 'bundler', '~>1.3' + spec.add_development_dependency 'bundler' + spec.add_development_dependency 'pry' spec.add_development_dependency 'rake' spec.add_development_dependency 'rspec', '~>3' + spec.add_development_dependency 'rubocop' + spec.add_development_dependency 'webmock', '~>3' - spec.add_dependency 'money', '~> 6.7.0' - spec.add_dependency 'savon', '~>2.0' + spec.add_dependency 'httparty', '~>0' + spec.add_dependency 'money', '~> 6' end diff --git a/spec/integration/update_rates_spec.rb b/spec/integration/update_rates_spec.rb new file mode 100644 index 0000000..26dff16 --- /dev/null +++ b/spec/integration/update_rates_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' + +describe 'Update Rates' do + let(:bank) { Money::Bank::RussianCentralBank.new } + let(:rates_xml) { File.read('spec/support/rcb_response.xml') } + let(:date) { Date.today } + let(:rates_url) do + "#{Money::Bank::RussianCentralBankFetcher::DAILY_RATES_URL}?date_req=#{date.strftime('%d/%m/%Y')}" + end + + context 'when cbr.ru successfully returns rates' do + before do + stub_request(:get, rates_url).to_return( + status: 200, + body: rates_xml, + headers: { + "content-type"=>["application/xml; charset=windows-1251"] + } + ) + + bank.update_rates + end + + it 'adds rates', :aggregete_failure do + expect(bank.get_rate('USD', 'RUB')).to eq(64.4993) + expect(bank.get_rate('RUB', 'USD')).to eq(0.015504044229937378) + end + + it 'adds rates for currencies with nominal > 1', :aggregete_failure do + expect(bank.get_rate('DKK', 'RUB')).to eq(9.76581) + expect(bank.get_rate('RUB', 'DKK')).to eq(0.10239806017114812) + end + end + + context 'when cbr.ru request fails' do + before do + stub_request(:get, rates_url).to_return( + status: 503, + body: '503' + ) + end + + it 'raises FetchError' do + expect { bank.update_rates }.to raise_error(Money::Bank::RussianCentralBankFetcher::FetchError) + end + end +end diff --git a/spec/lib/money/bank/russian_central_bank_fetcher_spec.rb b/spec/lib/money/bank/russian_central_bank_fetcher_spec.rb new file mode 100644 index 0000000..31bd3df --- /dev/null +++ b/spec/lib/money/bank/russian_central_bank_fetcher_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +describe Money::Bank::RussianCentralBankFetcher do + describe 'perform' do + subject(:perform) { fetcher.perform(date) } + + let(:date) { Date.today } + let(:fetcher) { described_class.new } + let(:rates_url) do + "http://www.cbr.ru/scripts/XML_daily.asp?date_req=#{date.strftime('%d/%m/%Y')}" + end + + context 'when RCB responds' do + let(:rcb_response) do + instance_double( + 'response', + parsed_response: parsed_response, + success?: response_is_success, + code: response_code + ) + end + let(:parsed_response) do + { + 'ValCurs' => { + 'Valute' => [ + { + 'CharCode' => 'XXX', + 'Nominal' => '1', + 'Value' => '100,1' + } + ] + } + } + end + + before do + allow(HTTParty).to receive(:get).with(rates_url).and_return(rcb_response) + end + + context 'and respond is successfull' do + let(:response_is_success) { true } + let(:response_code) { 200 } + + it 'returns a normalized array of rates' do + expect(perform).to eq( + [ + { + code: 'XXX', nominal: 1, value: 100.1 + } + ] + ) + end + end + + context 'and repsond is not successfull' do + let(:response_is_success) { false } + let(:response_code) { 503 } + + it 'raises FetchError' do + expect { perform }.to raise_error(Money::Bank::RussianCentralBankFetcher::FetchError) + end + end + end + + context 'when RCB fails to respond' do + let(:error_message) { 'RCB failed to response' } + + before do + allow(HTTParty).to receive(:get).with(rates_url).and_raise(HTTParty::Error, error_message) + end + + it 'rescues an exception and raises FetchError' do + expect { perform }.to raise_error(Money::Bank::RussianCentralBankFetcher::FetchError, error_message) + end + end + end +end diff --git a/spec/lib/money/bank/russian_central_bank_spec.rb b/spec/lib/money/bank/russian_central_bank_spec.rb new file mode 100644 index 0000000..6259e17 --- /dev/null +++ b/spec/lib/money/bank/russian_central_bank_spec.rb @@ -0,0 +1,112 @@ +require 'spec_helper' + +describe Money::Bank::RussianCentralBank do + subject(:bank) { described_class.new } + + describe '#update_rates' do + let(:fetcher) { instance_double(Money::Bank::RussianCentralBankFetcher, perform: rates) } + let(:rates) do + [ + { code: 'USD', value: 32.4288, nominal: 1 }, + { code: 'EUR', value: 42.5920, nominal: 1 }, + { code: 'JPY', value: 32.4029, nominal: 100 } + ] + end + + before do + allow(Money::Bank::RussianCentralBankFetcher).to receive(:new).and_return(fetcher) + + bank.update_rates + end + + it 'should update rates from daily rates service' do + expect(bank.rates['RUB_TO_USD']).to eq(0.03083678705348332) + expect(bank.rates['RUB_TO_EUR']).to eq(0.023478587528174305) + expect(bank.rates['RUB_TO_JPY']).to eq(3.086143524190736) + end + end + + describe '#flush_rates' do + before do + bank.add_rate('RUB', 'USD', 0.03) + end + + it 'should delete all rates' do + bank.get_rate('RUB', 'USD') + bank.flush_rates + expect(bank.store.send(:index)).to be_empty + end + end + + describe '#get_rate' do + context 'when getting direct rates' do + before do + bank.flush_rates + bank.add_rate('RUB', 'USD', 0.03) + bank.add_rate('RUB', 'GBP', 0.02) + end + + it 'should get rate from @rates' do + expect(bank.get_rate('RUB', 'USD')).to eq(0.03) + end + + it 'should calculate indirect rates' do + expect(bank.get_rate('USD', 'GBP')).to eq(0.6666666666666667) + end + end + + context 'when getting indirect rate' do + let(:indirect_rate) { 4 } + + before do + bank.flush_rates + bank.add_rate('RUB', 'USD', 123) + bank.add_rate('USD', 'RUB', indirect_rate) + end + + it 'gets indirect rate from the last set' do + expect(bank.get_rate('RUB', 'USD')).to eq(1.0 / indirect_rate) + end + end + + context 'when ttl is not set' do + before do + bank.add_rate('RUB', 'USD', 123) + bank.ttl = nil + end + + it 'should not update rates' do + expect(bank).to_not receive(:update_rates) + bank.get_rate('RUB', 'USD') + end + end + + context 'when ttl is set' do + before { bank.add_rate('RUB', 'USD', 123) } + + context 'and raks are expired' do + before do + bank.instance_variable_set('@rates_updated_at', Time.now - 3600) + bank.ttl = 3600 + end + + it 'should update rates' do + expect(bank).to receive(:update_rates) + bank.get_rate('RUB', 'USD') + end + end + + context 'and ranks are not expired' do + before do + bank.instance_variable_set('@rates_updated_at', Time.now - 3000) + bank.ttl = 3600 + end + + it 'should not update rates' do + expect(bank).to_not receive(:update_rates) + bank.get_rate('RUB', 'USD') + end + end + end + end +end diff --git a/spec/russian_central_bank_spec.rb b/spec/russian_central_bank_spec.rb deleted file mode 100644 index 1c0f4a9..0000000 --- a/spec/russian_central_bank_spec.rb +++ /dev/null @@ -1,108 +0,0 @@ -require File.expand_path(File.dirname(__FILE__) + '/spec_helper') - -describe 'RussianCentralBank' do - before do - rates_hash = symbolize_keys YAML::load(File.open('spec/support/daily_rates.yml')) - allow_any_instance_of(Savon::Client).to receive_message_chain(:call, :body) { rates_hash } - end - - before :each do - @bank = Money::Bank::RussianCentralBank.new - end - - describe '#update_rates' do - before do - @bank.update_rates - end - - it 'should update rates from daily rates service' do - expect(@bank.rates['RUB_TO_USD']).to eq(0.03083678705348332) - expect(@bank.rates['RUB_TO_EUR']).to eq(0.023478587528174305) - expect(@bank.rates['RUB_TO_JPY']).to eq(3.086143524190736) - end - end - - describe '#flush_rates' do - before do - @bank.add_rate('RUB', 'USD', 0.03) - end - - it 'should delete all rates' do - @bank.get_rate('RUB', 'USD') - @bank.flush_rates - expect(@bank.store.send(:index)).to be_empty - end - end - - describe '#get_rate' do - context 'getting dicrect rates' do - before do - @bank.flush_rates - @bank.add_rate('RUB', 'USD', 0.03) - @bank.add_rate('RUB', 'GBP', 0.02) - end - - it 'should get rate from @rates' do - expect(@bank.get_rate('RUB', 'USD')).to eq(0.03) - end - - it 'should calculate indirect rates' do - expect(@bank.get_rate('USD', 'GBP')).to eq(0.6666666666666667) - end - end - - context 'getting indirect rate' do - let(:indirect_rate) { 4 } - - before do - @bank.flush_rates - @bank.add_rate('RUB', 'USD', 123) - @bank.add_rate('USD', 'RUB', indirect_rate) - end - - it 'gets indirect rate from the last set' do - expect(@bank.get_rate('RUB', 'USD')).to eq(1.0 / indirect_rate) - end - end - - context "when ttl is not set" do - before do - @bank.add_rate('RUB', 'USD', 123) - @bank.ttl = nil - end - - it "should not update rates" do - expect(@bank).to_not receive(:update_rates) - @bank.get_rate('RUB', 'USD') - end - end - - context "when ttl is set" do - before { @bank.add_rate('RUB', 'USD', 123) } - - context "and raks are expired" do - before do - @bank.instance_variable_set('@rates_updated_at', Time.now - 3600) - @bank.ttl = 3600 - end - - it "should update rates" do - expect(@bank).to receive(:update_rates) - @bank.get_rate('RUB', 'USD') - end - end - - context "and ranks are not expired" do - before do - @bank.instance_variable_set('@rates_updated_at', Time.now - 3000) - @bank.ttl = 3600 - end - - it "should not update rates" do - expect(@bank).to_not receive(:update_rates) - @bank.get_rate('RUB', 'USD') - end - end - end - end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 07f6c47..be88192 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,11 +1,8 @@ require 'russian_central_bank' -require 'savon' -require 'support/helpers' - +require 'webmock/rspec' RSpec.configure do |config| config.color = true config.tty = true - config.order = :random end diff --git a/spec/support/daily_rates.yml b/spec/support/daily_rates.yml deleted file mode 100644 index 63e9a9d..0000000 --- a/spec/support/daily_rates.yml +++ /dev/null @@ -1,8 +0,0 @@ -get_curs_on_date_response: - get_curs_on_date_result: - diffgram: - valute_data: - valute_curs_on_date: - - {vname: "US Dollar", vnom: "1", vcurs: "32.4288", vcode: "840", vch_code: "USD"} - - {vname: "Euro", vnom: "1", vcurs: "42.5920", vcode: "978", vch_code: "EUR"} - - {vname: "Japanese yen", vnom: "100", vcurs: "32.4029", vcode: "392", vch_code: "JPY"} diff --git a/spec/support/helpers.rb b/spec/support/helpers.rb deleted file mode 100644 index e954905..0000000 --- a/spec/support/helpers.rb +++ /dev/null @@ -1,15 +0,0 @@ -def symbolize_keys(hash) - hash.inject({}){|result, (key, value)| - new_key = case key - when String then key.to_sym - else key - end - new_value = case value - when Hash then symbolize_keys(value) - when Array then value.map{ |v| v.is_a?(Hash) ? symbolize_keys(v) : v } - else value - end - result[new_key] = new_value - result - } -end diff --git a/spec/support/rcb_response.xml b/spec/support/rcb_response.xml new file mode 100644 index 0000000..9b0a8b1 --- /dev/null +++ b/spec/support/rcb_response.xml @@ -0,0 +1,53 @@ + + + + + 036 + AUD + 1 + Австралийский доллар + 45,7365 + + + 826 + GBP + 1 + Фунт стерлингов Соединенного королевства + 85,0552 + + + 344 + HKD + 10 + Гонконгских долларов + 82,1815 + + + 208 + DKK + 10 + Датских крон + 97,6581 + + + 840 + USD + 1 + Доллар США + 64,4993 + + + 978 + EUR + 1 + Евро + 72,9229 + + + 392 + JPY + 100 + Японских иен + 58,5958 + + \ No newline at end of file