spk/money-open-exchange-rates

View on GitHub
lib/money/bank/open_exchange_rates_bank.rb

Summary

Maintainability
A
3 hrs
Test Coverage
# frozen_string_literal: true

require 'json'
require 'money'
require 'net/http'
require 'time'
require 'uri'
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

    # Access restricted (e.g. usage/request limit exceeded for account)
    class AccessRestricted < StandardError; end

    # app_id_inactive
    class AppIdInactive < StandardError; end

    ERROR_MAP = {
      access_restricted: AccessRestricted,
      app_id_inactive: AppIdInactive
    }.freeze

    # 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)
        if doc['error'] && ERROR_MAP.key?(doc['message'].to_sym)
          raise ERROR_MAP[doc['message'].to_sym]
        end

        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