PageRenderTime 39ms CodeModel.GetById 11ms RepoModel.GetById 1ms app.codeStats 0ms

/lib/money/bank/open_exchange_rates_bank.rb

https://gitlab.com/spkdev/money-open-exchange-rates
Ruby | 432 lines | 189 code | 53 blank | 190 comment | 20 complexity | da1e053132d69ba11b708a8068c9ab10 MD5 | raw file
  1. # frozen_string_literal: true
  2. require 'net/http'
  3. require 'uri'
  4. require 'money'
  5. require 'json'
  6. require File.expand_path('../../open_exchange_rates_bank/version', __dir__)
  7. # Money gem class
  8. # rubocop:disable Metrics/ClassLength
  9. class Money
  10. # https://github.com/RubyMoney/money#exchange-rate-stores
  11. module Bank
  12. # Invalid cache, file not found or cache empty
  13. class InvalidCache < StandardError; end
  14. # APP_ID not set error
  15. class NoAppId < StandardError; end
  16. # OpenExchangeRatesBank base class
  17. class OpenExchangeRatesBank < Money::Bank::VariableExchange
  18. VERSION = ::OpenExchangeRatesBank::VERSION
  19. BASE_URL = 'https://openexchangerates.org/api/'
  20. # OpenExchangeRates urls
  21. OER_URL = URI.join(BASE_URL, 'latest.json')
  22. OER_HISTORICAL_URL = URI.join(BASE_URL, 'historical/')
  23. # Default base currency "base": "USD"
  24. OE_SOURCE = 'USD'
  25. RATES_KEY = 'rates'
  26. TIMESTAMP_KEY = 'timestamp'
  27. # As of the end of August 2012 all requests to the Open Exchange Rates
  28. # API must have a valid app_id
  29. # see https://docs.openexchangerates.org/docs/authentication
  30. #
  31. # @example
  32. # oxr.app_id = 'YOUR_APP_APP_ID'
  33. #
  34. # @param [String] token to access OXR API
  35. # @return [String] token to access OXR API
  36. attr_accessor :app_id
  37. # Cache accessor
  38. #
  39. # @example
  40. # oxr.cache = 'path/to/file/cache.json'
  41. #
  42. # @param [String,Proc] for a String a filepath
  43. # @return [String,Proc] for a String a filepath
  44. attr_accessor :cache
  45. # Date for historical api
  46. # see https://docs.openexchangerates.org/docs/historical-json
  47. #
  48. # @example
  49. # oxr.date = '2015-01-01'
  50. #
  51. # @param [String] The requested date in YYYY-MM-DD format
  52. # @return [String] The requested date in YYYY-MM-DD format
  53. attr_accessor :date
  54. # Force refresh rates cache and store on the fly when ttl is expired
  55. # This will slow down request on get_rate, so use at your on risk, if you
  56. # don't want to setup crontab/worker/scheduler for your application
  57. #
  58. # @param [Boolean]
  59. attr_accessor :force_refresh_rate_on_expire
  60. # Rates expiration Time
  61. #
  62. # @return [Time] expiration time
  63. attr_reader :rates_expiration
  64. # Parsed OpenExchangeRates result as Hash
  65. #
  66. # @return [Hash] All rates as Hash
  67. attr_reader :oer_rates
  68. # Unparsed OpenExchangeRates response as String
  69. #
  70. # @return [String] OpenExchangeRates json response
  71. attr_reader :json_response
  72. # Seconds after than the current rates are automatically expired
  73. #
  74. # @return [Integer] Setted time to live in seconds
  75. attr_reader :ttl_in_seconds
  76. # Set support for the black market and alternative digital currencies
  77. # see https://docs.openexchangerates.org/docs/alternative-currencies
  78. # @example
  79. # oxr.show_alternative = true
  80. #
  81. # @param [Boolean] if true show alternative
  82. # @return [Boolean] Setted show alternative
  83. attr_writer :show_alternative
  84. # Filter response to a list of symbols
  85. # see https://docs.openexchangerates.org/docs/get-specific-currencies
  86. # @example
  87. # oxr.symbols = [:usd, :cad]
  88. #
  89. # @param [Array] list of symbols
  90. # @return [Array] Setted list of symbols
  91. attr_writer :symbols
  92. # Minified Response ('prettyprint')
  93. # see https://docs.openexchangerates.org/docs/prettyprint
  94. # @example
  95. # oxr.prettyprint = false
  96. #
  97. # @param [Boolean] Set to false to receive minified (default: true)
  98. # @return [Boolean]
  99. attr_writer :prettyprint
  100. # Set current rates timestamp
  101. #
  102. # @return [Time]
  103. def rates_timestamp=(at)
  104. @rates_timestamp = Time.at(at)
  105. end
  106. # Current rates timestamp
  107. #
  108. # @return [Time]
  109. def rates_timestamp
  110. @rates_timestamp || Time.now
  111. end
  112. # Set the seconds after than the current rates are automatically expired
  113. # by default, they never expire.
  114. #
  115. # @example
  116. # ttl_in_seconds = 86400 # will expire the rates in one day
  117. #
  118. # @param value [Integer] Time to live in seconds
  119. #
  120. # @return [Integer] Setted time to live in seconds
  121. def ttl_in_seconds=(value)
  122. @ttl_in_seconds = value
  123. refresh_rates_expiration if ttl_in_seconds
  124. ttl_in_seconds
  125. end
  126. # Set the base currency for all rates. By default, USD is used.
  127. # OpenExchangeRates only allows USD as base currency
  128. # for the free plan users.
  129. #
  130. # @example
  131. # oxr.source = 'USD'
  132. #
  133. # @param value [String] Currency code, ISO 3166-1 alpha-3
  134. #
  135. # @return [String] chosen base currency
  136. def source=(value)
  137. scurrency = Money::Currency.find(value.to_s)
  138. @source = if scurrency
  139. scurrency.iso_code
  140. else
  141. OE_SOURCE
  142. end
  143. end
  144. # Get the base currency for all rates. By default, USD is used.
  145. #
  146. # @return [String] base currency
  147. def source
  148. @source ||= OE_SOURCE
  149. end
  150. # Update all rates from openexchangerates JSON
  151. #
  152. # @return [Array] Array of exchange rates
  153. def update_rates
  154. store.transaction do
  155. clear_rates!
  156. exchange_rates.each do |exchange_rate|
  157. rate = exchange_rate.last
  158. currency = exchange_rate.first
  159. next unless Money::Currency.find(currency)
  160. set_rate(source, currency, rate)
  161. set_rate(currency, source, 1.0 / rate)
  162. end
  163. end
  164. end
  165. # Alias super method
  166. alias super_get_rate get_rate
  167. # Override Money `get_rate` method for caching
  168. #
  169. # @param [String] from_currency Currency ISO code. ex. 'USD'
  170. # @param [String] to_currency Currency ISO code. ex. 'CAD'
  171. #
  172. # @return [Numeric] rate.
  173. def get_rate(from_currency, to_currency, opts = {})
  174. super if opts[:call_super]
  175. expire_rates
  176. rate = get_rate_or_calc_inverse(from_currency, to_currency, opts)
  177. rate || calc_pair_rate_using_base(from_currency, to_currency, opts)
  178. end
  179. # Fetch from url and save cache
  180. #
  181. # @return [Array] Array of exchange rates
  182. def refresh_rates
  183. read_from_url
  184. end
  185. # Alias refresh_rates method
  186. alias save_rates refresh_rates
  187. # Expire rates when expired
  188. #
  189. # @return [NilClass, Time] nil if not expired or new expiration time
  190. def expire_rates
  191. return unless ttl_in_seconds
  192. return if rates_expiration > Time.now
  193. refresh_rates if force_refresh_rate_on_expire
  194. update_rates
  195. refresh_rates_expiration
  196. end
  197. # Get show alternative
  198. #
  199. # @return [Boolean] if true show alternative
  200. def show_alternative
  201. @show_alternative ||= false
  202. end
  203. # Get prettyprint option
  204. #
  205. # @return [Boolean]
  206. def prettyprint
  207. return true unless defined? @prettyprint
  208. return true if @prettyprint.nil?
  209. @prettyprint
  210. end
  211. # Get symbols
  212. #
  213. # @return [Array] list of symbols to filter by
  214. def symbols
  215. @symbols ||= nil
  216. end
  217. # Source url of openexchangerates
  218. # defined with app_id
  219. #
  220. # @return [String] URL
  221. def source_url
  222. str = "#{oer_url}?app_id=#{app_id}"
  223. str = "#{str}&base=#{source}" unless source == OE_SOURCE
  224. str = "#{str}&show_alternative=#{show_alternative}"
  225. str = "#{str}&prettyprint=#{prettyprint}"
  226. str = "#{str}&symbols=#{symbols.join(',')}" if symbols&.is_a?(Array)
  227. str
  228. end
  229. protected
  230. # Save rates on cache
  231. # Can raise InvalidCache
  232. #
  233. # @return [Proc,File]
  234. def save_cache
  235. store_in_cache(@json_response) if valid_rates?(@json_response)
  236. rescue Errno::ENOENT
  237. raise InvalidCache
  238. end
  239. # Latest url if no date given
  240. #
  241. # @return [String] URL
  242. def oer_url
  243. if date
  244. historical_url
  245. else
  246. latest_url
  247. end
  248. end
  249. # Historical url generated from `date` attr_accessor
  250. #
  251. # @return [String] URL
  252. def historical_url
  253. URI.join(OER_HISTORICAL_URL, "#{date}.json")
  254. end
  255. # Latest url
  256. #
  257. # @return [String] URL
  258. def latest_url
  259. OER_URL
  260. end
  261. # Store the provided text data by calling the proc method provided
  262. # for the cache, or write to the cache file.
  263. # Can raise InvalidCache
  264. #
  265. # @example
  266. # oxr.store_in_cache("{\"rates\": {\"AED\": 3.67304}}")
  267. #
  268. # @param text [String] String to cache
  269. # @return [String,Integer]
  270. def store_in_cache(text)
  271. if cache.is_a?(Proc)
  272. cache.call(text)
  273. elsif cache.is_a?(String) || cache.is_a?(Pathname)
  274. File.open(cache.to_s, 'w') do |f|
  275. f.write(text)
  276. end
  277. else
  278. raise InvalidCache
  279. end
  280. end
  281. # Read from cache when exist
  282. #
  283. # @return [String] Raw string from file or cache proc
  284. def read_from_cache
  285. result = if cache.is_a?(Proc)
  286. cache.call(nil)
  287. elsif File.exist?(cache.to_s)
  288. File.read(cache)
  289. end
  290. result if valid_rates?(result)
  291. end
  292. # Read API
  293. #
  294. # @return [String]
  295. def api_response
  296. Net::HTTP.get(URI(source_url))
  297. end
  298. # Read from url
  299. #
  300. # @return [String] JSON content
  301. def read_from_url
  302. raise NoAppId if app_id.nil? || app_id.empty?
  303. @json_response = api_response
  304. save_cache if cache
  305. @json_response
  306. end
  307. # Check validity of rates response only for store in cache
  308. #
  309. # @example
  310. # oxr.valid_rates?("{\"rates\": {\"AED\": 3.67304}}")
  311. #
  312. # @param [String] text is JSON content
  313. # @return [Boolean] valid or not
  314. def valid_rates?(text)
  315. return false unless text
  316. parsed = JSON.parse(text)
  317. parsed&.key?(RATES_KEY) && parsed&.key?(TIMESTAMP_KEY)
  318. rescue JSON::ParserError
  319. false
  320. end
  321. # Get expire rates, first from cache and then from url
  322. #
  323. # @return [Hash] key is country code (ISO 3166-1 alpha-3) value Float
  324. def exchange_rates
  325. doc = JSON.parse(read_from_cache || read_from_url)
  326. self.rates_timestamp = doc[TIMESTAMP_KEY]
  327. @oer_rates = doc[RATES_KEY]
  328. end
  329. # Refresh expiration from now
  330. #
  331. # @return [Time] new expiration time
  332. def refresh_rates_expiration
  333. @rates_expiration = rates_timestamp + ttl_in_seconds
  334. end
  335. # Get rate or calculate it as inverse rate
  336. #
  337. # @param [String] from_currency Currency ISO code. ex. 'USD'
  338. # @param [String] to_currency Currency ISO code. ex. 'CAD'
  339. #
  340. # @return [Numeric] rate or rate calculated as inverse rate.
  341. def get_rate_or_calc_inverse(from_currency, to_currency, opts = {})
  342. rate = super_get_rate(from_currency, to_currency, opts)
  343. unless rate
  344. # Tries to calculate an inverse rate
  345. inverse_rate = super_get_rate(to_currency, from_currency, opts)
  346. if inverse_rate
  347. rate = 1.0 / inverse_rate
  348. add_rate(from_currency, to_currency, rate)
  349. end
  350. end
  351. rate
  352. end
  353. # Tries to calculate a pair rate using base currency rate
  354. #
  355. # @param [String] from_currency Currency ISO code. ex. 'USD'
  356. # @param [String] to_currency Currency ISO code. ex. 'CAD'
  357. #
  358. # @return [Numeric] rate or nil if cannot calculate rate.
  359. def calc_pair_rate_using_base(from_currency, to_currency, opts)
  360. from_base_rate = get_rate_or_calc_inverse(source, from_currency, opts)
  361. to_base_rate = get_rate_or_calc_inverse(source, to_currency, opts)
  362. return unless to_base_rate
  363. return unless from_base_rate
  364. rate = BigDecimal(to_base_rate.to_s) / from_base_rate
  365. add_rate(from_currency, to_currency, rate)
  366. rate
  367. end
  368. # Clears cached rates in store
  369. #
  370. # @return [Hash] All rates from store as Hash
  371. def clear_rates!
  372. store.each_rate do |iso_from, iso_to|
  373. add_rate(iso_from, iso_to, nil)
  374. end
  375. end
  376. end
  377. end
  378. end
  379. # rubocop:enable Metrics/ClassLength