-
Notifications
You must be signed in to change notification settings - Fork 15
/
currencylayer_bank.rb
354 lines (312 loc) · 11.3 KB
/
currencylayer_bank.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
# frozen_string_literal: true
require 'open-uri'
require 'money'
require 'json'
# Money gem class
class Money
# Build in memory rates store
module RatesStore
# Memory class
class Memory
# Add method to reset the build in memory store
# @param [Hash] rtd Optional initial exchange rate data.
# @return [Object] store.
def reset!(rtd = {})
transaction { @index = rtd }
end
end
end
# 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 NoAccessKey < StandardError; end
# CurrencylayerBank base class
class CurrencylayerBank < Money::Bank::VariableExchange
# Apilayer url
URL_AL = 'http://api.apilayer.com/currency_data/live'.freeze
# CurrencylayerBank url
URL_CL = 'http://api.currencylayer.com/live'.freeze
# Default base currency
SOURCE = 'USD'.freeze
# Use new or old endpoint.
# new: api.apilayer.com
# old: api.currencylayer.com
#
# @param value [Boolean] true for old endpoint
# @return [Boolean] chosen old endpoint if true
attr_accessor :currencylayer
# Use https to fetch rates from CurrencylayerBank
# CurrencylayerBank only allows http as connection
# for the free plan users.
#
# @param value [Boolean] true for secure connection
# @return [Boolean] chosen secure connection
attr_accessor :secure_connection
# API must have a valid access_key
#
# @param value [String] API access key
# @return [String] chosen API access key
attr_accessor :access_key
# Rescue with rates from the cache instead of reporting an
# error when the endpoint fails.
#
# @param value [Boolean] true for rescue error with cache rates
# @return [Boolean] chosen rescue with cache rates
attr_accessor :rescue_with_cache
# Cache accessor, can be a String or a Proc
#
# @param value [String,Pathname,Proc] cache system
# @return [String,Pathname,Proc] chosen cache system
attr_accessor :cache
# Parsed CurrencylayerBank result as Hash
attr_reader :rates
# Get the timestamp of rates in memory
# @return [Time] time object or nil
attr_reader :rates_mem_timestamp
# 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] chosen time to live in seconds
attr_writer :ttl_in_seconds
# Set the base currency for all rates. By default, USD is used.
# CurrencylayerBank only allows USD as base currency
# for the free plan users.
#
# @example
# source = 'USD'
#
# @param value [String] Currency code, ISO 3166-1 alpha-3
# @return [String] chosen base currency
def source=(value)
@source = Money::Currency.find(value.to_s).try(:iso_code) || SOURCE
end
# Get the base currency for all rates. By default, USD is used.
# @return [String] base currency
def source
@source ||= SOURCE
end
# Get the seconds after than the current rates are automatically expired
# by default, they never expire.
# @return [Integer] chosen time to live in seconds
def ttl_in_seconds
@ttl_in_seconds ||= 0
end
# Update all rates from CurrencylayerBank JSON
# @return [Array] array of exchange rates
def update_rates(straight = false)
store.reset!
rates = exchange_rates(straight).each do |exchange_rate|
currency = exchange_rate.first[3..-1]
rate = exchange_rate.last
next unless Money::Currency.find(currency)
add_rate(source, currency, rate)
add_rate(currency, source, 1.0 / rate)
end
@rates_mem_timestamp = rates_timestamp
rates
end
# Override Money `add_rate` method for caching
# @param [String] from_currency Currency ISO code. ex. 'USD'
# @param [String] to_currency Currency ISO code. ex. 'CAD'
# @param [Numeric] rate Rate to use when exchanging currencies.
# @return [Numeric] rate.
def add_rate(from_currency, to_currency, rate) # rubocop:disable Lint/UselessMethodDefinition
super
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'
# @param [Hash] opts Options hash to set special parameters.
# @return [Numeric] rate.
def get_rate(from_currency, to_currency, opts = {})
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 new rates if cached rates are expired or stale
# @return [Boolean] true if rates are expired and updated from remote
def expire_rates!
if expired?
update_rates(true)
true
elsif stale?
update_rates
true
else
false
end
end
# Check if rates are expired
# @return [Boolean] true if rates are expired
def expired?
return false if ttl_in_seconds.zero?
Time.now > rates_expiration
end
# Check if rates are stale
# Stale is true if rates are updated straight by another thread.
# The actual thread has always old rates in memory store.
# @return [Boolean] true if rates are stale
def stale?
rates_timestamp != rates_mem_timestamp
end
# Source url of CurrencylayerBank
# defined with access_key and secure_connection
# @return [String] the remote API url
def source_url
raise NoAccessKey if access_key.nil? || access_key.empty?
url = "#{currencylayer ? URL_CL : URL_AL}?source=#{source}"
url = url.sub('http:', 'https:') if secure_connection
url = "#{url}&access_key=#{access_key}" if currencylayer
url
end
# Get rates expiration time based on ttl
# @return [Time] rates expiration time
def rates_expiration
rates_timestamp + ttl_in_seconds
end
# Get the timestamp of rates
# @return [Time] time object or nil
def rates_timestamp
raw = raw_rates_careful
raw.key?('timestamp') ? Time.at(raw['timestamp']) : Time.at(0)
end
protected
# Store the provided text data by calling the proc method provided
# for the cache, or write to the cache file.
#
# @example
# store_in_cache("{\"quotes\": {\"USDAED\": 3.67304}}")
#
# @param text [String] parsed JSON content
# @return [String,Integer]
def store_in_cache(text)
case cache
when Proc
cache.call(text)
when String, Pathname
write_to_file(text)
end
end
# Writes content to file cache
# @param text [String] parsed JSON content
# @return [String,Integer]
def write_to_file(text)
open(cache, 'w') do |f|
f.write(text)
end
rescue Errno::ENOENT
raise InvalidCache
end
# Read from cache when exist
# @return [Proc,String] parsed JSON content
def read_from_cache
if cache.is_a?(Proc)
cache.call(nil)
elsif (cache.is_a?(String) || cache.is_a?(Pathname)) &&
File.exist?(cache)
open(cache).read
end
end
# Get remote content and store in cache
# @return [String] unparsed JSON content
def read_from_url
text = open_url
store_in_cache(text) if valid_rates?(text) && cache
text
end
# Opens an url and reads the content
# @return [String] unparsed JSON content
def open_url
currencylayer ? URI.open(source_url).read : URI.open(source_url, 'apikey' => access_key).read
rescue OpenURI::HTTPError
''
end
# Check validity of rates response only for store in cache
#
# @example
# valid_rates?("{\"quotes\": {\"USDAED\": 3.67304}}")
#
# @param [String] text is JSON content
# @return [Boolean] valid or not
def valid_rates?(text)
parsed = JSON.parse(text)
parsed && parsed.key?('quotes')
rescue JSON::ParserError
false
end
# Get exchange rates with different strategies
#
# @example
# exchange_rates(true)
# exchange_rates
#
# @param straight [Boolean] true for straight, default is careful
# @return [Hash] key is country code (ISO 3166-1 alpha-3) value Float
def exchange_rates(straight = false)
rates = straight ? raw_rates_straight : raw_rates_careful
if rates.key?('quotes')
@rates = rates['quotes']
elsif rates.key?('error')
raise Error, rates.dig('error', 'info') unless rescue_with_cache
rates = raw_rates_careful(false)
@rates = rates.key?('quotes') ? rates['quotes'] : { 'quotes' => {} }
else
raise Error, 'Unknown rates situation!'
end
end
# Get raw exchange rates from cache and then from url
# @param rescue_straight [Boolean] true for rescue straight, default true
# @return [String] JSON content
def raw_rates_careful(rescue_straight = true)
JSON.parse(read_from_cache.to_s)
rescue JSON::ParserError
rescue_straight ? raw_rates_straight : { 'quotes' => {} }
end
# Get raw exchange rates from url
# @return [String] JSON content
def raw_rates_straight
JSON.parse(read_from_url)
rescue JSON::ParserError
raw_rates_careful(false)
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)
if to_base_rate && from_base_rate
rate = to_base_rate / from_base_rate
add_rate(from_currency, to_currency, rate)
return rate
end
nil
end
end
end
end