/lib/money/bank/open_exchange_rates_bank.rb
Ruby | 432 lines | 189 code | 53 blank | 190 comment | 20 complexity | da1e053132d69ba11b708a8068c9ab10 MD5 | raw file
- # frozen_string_literal: true
- require 'net/http'
- require 'uri'
- require 'money'
- require 'json'
- require File.expand_path('../../open_exchange_rates_bank/version', __dir__)
- # Money gem class
- # rubocop:disable Metrics/ClassLength
- class Money
- # https://github.com/RubyMoney/money#exchange-rate-stores
- module Bank
- # Invalid cache, file not found or cache empty
- class InvalidCache < StandardError; end
- # APP_ID not set error
- class NoAppId < StandardError; end
- # OpenExchangeRatesBank base class
- class OpenExchangeRatesBank < Money::Bank::VariableExchange
- VERSION = ::OpenExchangeRatesBank::VERSION
- BASE_URL = 'https://openexchangerates.org/api/'
- # OpenExchangeRates urls
- OER_URL = URI.join(BASE_URL, 'latest.json')
- OER_HISTORICAL_URL = URI.join(BASE_URL, 'historical/')
- # Default base currency "base": "USD"
- OE_SOURCE = 'USD'
- RATES_KEY = 'rates'
- TIMESTAMP_KEY = 'timestamp'
- # As of the end of August 2012 all requests to the Open Exchange Rates
- # API must have a valid app_id
- # see https://docs.openexchangerates.org/docs/authentication
- #
- # @example
- # oxr.app_id = 'YOUR_APP_APP_ID'
- #
- # @param [String] token to access OXR API
- # @return [String] token to access OXR API
- attr_accessor :app_id
- # Cache accessor
- #
- # @example
- # oxr.cache = 'path/to/file/cache.json'
- #
- # @param [String,Proc] for a String a filepath
- # @return [String,Proc] for a String a filepath
- attr_accessor :cache
- # Date for historical api
- # see https://docs.openexchangerates.org/docs/historical-json
- #
- # @example
- # oxr.date = '2015-01-01'
- #
- # @param [String] The requested date in YYYY-MM-DD format
- # @return [String] The requested date in YYYY-MM-DD format
- attr_accessor :date
- # Force refresh rates cache and store on the fly when ttl is expired
- # This will slow down request on get_rate, so use at your on risk, if you
- # don't want to setup crontab/worker/scheduler for your application
- #
- # @param [Boolean]
- attr_accessor :force_refresh_rate_on_expire
- # Rates expiration Time
- #
- # @return [Time] expiration time
- attr_reader :rates_expiration
- # Parsed OpenExchangeRates result as Hash
- #
- # @return [Hash] All rates as Hash
- attr_reader :oer_rates
- # Unparsed OpenExchangeRates response as String
- #
- # @return [String] OpenExchangeRates json response
- attr_reader :json_response
- # Seconds after than the current rates are automatically expired
- #
- # @return [Integer] Setted time to live in seconds
- attr_reader :ttl_in_seconds
- # Set support for the black market and alternative digital currencies
- # see https://docs.openexchangerates.org/docs/alternative-currencies
- # @example
- # oxr.show_alternative = true
- #
- # @param [Boolean] if true show alternative
- # @return [Boolean] Setted show alternative
- attr_writer :show_alternative
- # Filter response to a list of symbols
- # see https://docs.openexchangerates.org/docs/get-specific-currencies
- # @example
- # oxr.symbols = [:usd, :cad]
- #
- # @param [Array] list of symbols
- # @return [Array] Setted list of symbols
- attr_writer :symbols
- # Minified Response ('prettyprint')
- # see https://docs.openexchangerates.org/docs/prettyprint
- # @example
- # oxr.prettyprint = false
- #
- # @param [Boolean] Set to false to receive minified (default: true)
- # @return [Boolean]
- attr_writer :prettyprint
- # Set current rates timestamp
- #
- # @return [Time]
- def rates_timestamp=(at)
- @rates_timestamp = Time.at(at)
- end
- # Current rates timestamp
- #
- # @return [Time]
- def rates_timestamp
- @rates_timestamp || Time.now
- end
- # Set the seconds after than the current rates are automatically expired
- # by default, they never expire.
- #
- # @example
- # ttl_in_seconds = 86400 # will expire the rates in one day
- #
- # @param value [Integer] Time to live in seconds
- #
- # @return [Integer] Setted time to live in seconds
- def ttl_in_seconds=(value)
- @ttl_in_seconds = value
- refresh_rates_expiration if ttl_in_seconds
- ttl_in_seconds
- end
- # Set the base currency for all rates. By default, USD is used.
- # OpenExchangeRates only allows USD as base currency
- # for the free plan users.
- #
- # @example
- # oxr.source = 'USD'
- #
- # @param value [String] Currency code, ISO 3166-1 alpha-3
- #
- # @return [String] chosen base currency
- def source=(value)
- scurrency = Money::Currency.find(value.to_s)
- @source = if scurrency
- scurrency.iso_code
- else
- OE_SOURCE
- end
- end
- # Get the base currency for all rates. By default, USD is used.
- #
- # @return [String] base currency
- def source
- @source ||= OE_SOURCE
- end
- # Update all rates from openexchangerates JSON
- #
- # @return [Array] Array of exchange rates
- def update_rates
- store.transaction do
- clear_rates!
- exchange_rates.each do |exchange_rate|
- rate = exchange_rate.last
- currency = exchange_rate.first
- next unless Money::Currency.find(currency)
- set_rate(source, currency, rate)
- set_rate(currency, source, 1.0 / rate)
- end
- end
- end
- # Alias super method
- alias super_get_rate get_rate
- # Override Money `get_rate` method for caching
- #
- # @param [String] from_currency Currency ISO code. ex. 'USD'
- # @param [String] to_currency Currency ISO code. ex. 'CAD'
- #
- # @return [Numeric] rate.
- def get_rate(from_currency, to_currency, opts = {})
- super if opts[:call_super]
- expire_rates
- rate = get_rate_or_calc_inverse(from_currency, to_currency, opts)
- rate || calc_pair_rate_using_base(from_currency, to_currency, opts)
- end
- # Fetch from url and save cache
- #
- # @return [Array] Array of exchange rates
- def refresh_rates
- read_from_url
- end
- # Alias refresh_rates method
- alias save_rates refresh_rates
- # Expire rates when expired
- #
- # @return [NilClass, Time] nil if not expired or new expiration time
- def expire_rates
- return unless ttl_in_seconds
- return if rates_expiration > Time.now
- refresh_rates if force_refresh_rate_on_expire
- update_rates
- refresh_rates_expiration
- end
- # Get show alternative
- #
- # @return [Boolean] if true show alternative
- def show_alternative
- @show_alternative ||= false
- end
- # Get prettyprint option
- #
- # @return [Boolean]
- def prettyprint
- return true unless defined? @prettyprint
- return true if @prettyprint.nil?
- @prettyprint
- end
- # Get symbols
- #
- # @return [Array] list of symbols to filter by
- def symbols
- @symbols ||= nil
- end
- # Source url of openexchangerates
- # defined with app_id
- #
- # @return [String] URL
- def source_url
- str = "#{oer_url}?app_id=#{app_id}"
- str = "#{str}&base=#{source}" unless source == OE_SOURCE
- str = "#{str}&show_alternative=#{show_alternative}"
- str = "#{str}&prettyprint=#{prettyprint}"
- str = "#{str}&symbols=#{symbols.join(',')}" if symbols&.is_a?(Array)
- str
- end
- protected
- # Save rates on cache
- # Can raise InvalidCache
- #
- # @return [Proc,File]
- def save_cache
- store_in_cache(@json_response) if valid_rates?(@json_response)
- rescue Errno::ENOENT
- raise InvalidCache
- end
- # Latest url if no date given
- #
- # @return [String] URL
- def oer_url
- if date
- historical_url
- else
- latest_url
- end
- end
- # Historical url generated from `date` attr_accessor
- #
- # @return [String] URL
- def historical_url
- URI.join(OER_HISTORICAL_URL, "#{date}.json")
- end
- # Latest url
- #
- # @return [String] URL
- def latest_url
- OER_URL
- end
- # Store the provided text data by calling the proc method provided
- # for the cache, or write to the cache file.
- # Can raise InvalidCache
- #
- # @example
- # oxr.store_in_cache("{\"rates\": {\"AED\": 3.67304}}")
- #
- # @param text [String] String to cache
- # @return [String,Integer]
- def store_in_cache(text)
- if cache.is_a?(Proc)
- cache.call(text)
- elsif cache.is_a?(String) || cache.is_a?(Pathname)
- File.open(cache.to_s, 'w') do |f|
- f.write(text)
- end
- else
- raise InvalidCache
- end
- end
- # Read from cache when exist
- #
- # @return [String] Raw string from file or cache proc
- def read_from_cache
- result = if cache.is_a?(Proc)
- cache.call(nil)
- elsif File.exist?(cache.to_s)
- File.read(cache)
- end
- result if valid_rates?(result)
- end
- # Read API
- #
- # @return [String]
- def api_response
- Net::HTTP.get(URI(source_url))
- end
- # Read from url
- #
- # @return [String] JSON content
- def read_from_url
- raise NoAppId if app_id.nil? || app_id.empty?
- @json_response = api_response
- save_cache if cache
- @json_response
- end
- # Check validity of rates response only for store in cache
- #
- # @example
- # oxr.valid_rates?("{\"rates\": {\"AED\": 3.67304}}")
- #
- # @param [String] text is JSON content
- # @return [Boolean] valid or not
- def valid_rates?(text)
- return false unless text
- parsed = JSON.parse(text)
- parsed&.key?(RATES_KEY) && parsed&.key?(TIMESTAMP_KEY)
- rescue JSON::ParserError
- false
- end
- # Get expire rates, first from cache and then from url
- #
- # @return [Hash] key is country code (ISO 3166-1 alpha-3) value Float
- def exchange_rates
- doc = JSON.parse(read_from_cache || read_from_url)
- self.rates_timestamp = doc[TIMESTAMP_KEY]
- @oer_rates = doc[RATES_KEY]
- end
- # Refresh expiration from now
- #
- # @return [Time] new expiration time
- def refresh_rates_expiration
- @rates_expiration = rates_timestamp + ttl_in_seconds
- end
- # Get rate or calculate it as inverse rate
- #
- # @param [String] from_currency Currency ISO code. ex. 'USD'
- # @param [String] to_currency Currency ISO code. ex. 'CAD'
- #
- # @return [Numeric] rate or rate calculated as inverse rate.
- def get_rate_or_calc_inverse(from_currency, to_currency, opts = {})
- rate = super_get_rate(from_currency, to_currency, opts)
- unless rate
- # Tries to calculate an inverse rate
- inverse_rate = super_get_rate(to_currency, from_currency, opts)
- if inverse_rate
- rate = 1.0 / inverse_rate
- add_rate(from_currency, to_currency, rate)
- end
- end
- rate
- end
- # Tries to calculate a pair rate using base currency rate
- #
- # @param [String] from_currency Currency ISO code. ex. 'USD'
- # @param [String] to_currency Currency ISO code. ex. 'CAD'
- #
- # @return [Numeric] rate or nil if cannot calculate rate.
- def calc_pair_rate_using_base(from_currency, to_currency, opts)
- from_base_rate = get_rate_or_calc_inverse(source, from_currency, opts)
- to_base_rate = get_rate_or_calc_inverse(source, to_currency, opts)
- return unless to_base_rate
- return unless from_base_rate
- rate = BigDecimal(to_base_rate.to_s) / from_base_rate
- add_rate(from_currency, to_currency, rate)
- rate
- end
- # Clears cached rates in store
- #
- # @return [Hash] All rates from store as Hash
- def clear_rates!
- store.each_rate do |iso_from, iso_to|
- add_rate(iso_from, iso_to, nil)
- end
- end
- end
- end
- end
- # rubocop:enable Metrics/ClassLength