1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-22 06:39:39 +02:00

Implement Synth as an exchange rate provider (#574)

* Implement Synth as an exchange rate provider

* Add assertions to provider interface test

* Assert the correct provider error is raised

* Remove unnecessary parens
This commit is contained in:
Jose Farias 2024-03-27 09:16:00 -06:00 committed by GitHub
parent a1b25f1c5b
commit 7ae25dd6df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 310 additions and 34 deletions

View file

@ -54,4 +54,7 @@ end
group :test do group :test do
gem "capybara" gem "capybara"
gem "selenium-webdriver" gem "selenium-webdriver"
gem "mocha"
gem "vcr"
gem "webmock"
end end

View file

@ -134,6 +134,9 @@ GEM
xpath (~> 3.2) xpath (~> 3.2)
concurrent-ruby (1.2.3) concurrent-ruby (1.2.3)
connection_pool (2.4.1) connection_pool (2.4.1)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6) crass (1.0.6)
date (3.3.4) date (3.3.4)
debug (1.9.1) debug (1.9.1)
@ -164,6 +167,7 @@ GEM
fugit (>= 1.1) fugit (>= 1.1)
railties (>= 6.0.0) railties (>= 6.0.0)
thor (>= 0.14.1) thor (>= 0.14.1)
hashdiff (1.1.0)
highline (3.0.1) highline (3.0.1)
hotwire-livereload (1.3.1) hotwire-livereload (1.3.1)
actioncable (>= 6.0.0) actioncable (>= 6.0.0)
@ -214,6 +218,8 @@ GEM
matrix (0.4.2) matrix (0.4.2)
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.21.2) minitest (5.21.2)
mocha (2.1.0)
ruby2_keywords (>= 0.0.5)
msgpack (1.7.2) msgpack (1.7.2)
net-http (0.4.1) net-http (0.4.1)
uri uri
@ -335,6 +341,7 @@ GEM
ruby-lsp (>= 0.14.2, < 0.15.0) ruby-lsp (>= 0.14.2, < 0.15.0)
sorbet-runtime (>= 0.5.9897) sorbet-runtime (>= 0.5.9897)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5)
rubyzip (2.3.2) rubyzip (2.3.2)
selenium-webdriver (4.18.1) selenium-webdriver (4.18.1)
base64 (~> 0.2) base64 (~> 0.2)
@ -371,11 +378,16 @@ GEM
unicode-display_width (2.5.0) unicode-display_width (2.5.0)
uri (0.13.0) uri (0.13.0)
useragent (0.16.10) useragent (0.16.10)
vcr (6.2.0)
web-console (4.2.1) web-console (4.2.1)
actionview (>= 6.0.0) actionview (>= 6.0.0)
activemodel (>= 6.0.0) activemodel (>= 6.0.0)
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 6.0.0)
webmock (3.23.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.1) webrick (1.8.1)
websocket (1.2.10) websocket (1.2.10)
websocket-driver (0.7.6) websocket-driver (0.7.6)
@ -408,6 +420,7 @@ DEPENDENCIES
inline_svg inline_svg
letter_opener letter_opener
lucide-rails! lucide-rails!
mocha
pagy pagy
pg (~> 1.5) pg (~> 1.5)
propshaft propshaft
@ -422,7 +435,9 @@ DEPENDENCIES
tailwindcss-rails tailwindcss-rails
turbo-rails turbo-rails
tzinfo-data tzinfo-data
vcr
web-console web-console
webmock
RUBY VERSION RUBY VERSION
ruby 3.3.0p0 ruby 3.3.0p0

View file

@ -62,7 +62,7 @@ module Account::Syncable
next if existing_rates_set.include?([ rc_from, rc_to, rc_date.to_s ]) next if existing_rates_set.include?([ rc_from, rc_to, rc_date.to_s ])
logger.info "Fetching exchange rate from provider for account #{self.name}: #{self.id} (#{rc_from} to #{rc_to} on #{rc_date})" logger.info "Fetching exchange rate from provider for account #{self.name}: #{self.id} (#{rc_from} to #{rc_to} on #{rc_date})"
rate = ExchangeRate.fetch_rate_from_provider(rc_from, rc_to, rc_date) rate = ExchangeRate.find_rate_or_fetch from: rc_from, to: rc_to, date: rc_date
ExchangeRate.create! base_currency: rc_from, converted_currency: rc_to, date: rc_date, rate: rate if rate ExchangeRate.create! base_currency: rc_from, converted_currency: rc_to, date: rc_date, rate: rate if rate
end end

View file

@ -0,0 +1,13 @@
# `Providable` serves as an extension point for integrating multiple providers.
# For an example of a multi-provider, multi-concept implementation,
# see: https://github.com/maybe-finance/maybe/pull/561
module Providable
extend ActiveSupport::Concern
class_methods do
def exchange_rates_provider
Provider::Synth.new
end
end
end

View file

@ -1,42 +1,22 @@
class ExchangeRate < ApplicationRecord class ExchangeRate < ApplicationRecord
include Provided
validates :base_currency, :converted_currency, presence: true validates :base_currency, :converted_currency, presence: true
class << self class << self
def convert(from, to, amount) def find_rate(from:, to:, date:)
rate = ExchangeRate.find_by(base_currency: from, converted_currency: to, date: Date.current) find_by \
return nil if rate.nil? base_currency: Money::Currency.new(from).iso_code,
amount * rate.rate converted_currency: Money::Currency.new(to).iso_code,
date: date
end end
def get_rate(from, to, date) def find_rate_or_fetch(from:, to:, date:)
_from = Money::Currency.new(from) find_rate(from:, to:, date:) || fetch_rate_from_provider(from:, to:, date:).tap(&:save!)
_to = Money::Currency.new(to)
find_by! base_currency: _from.iso_code, converted_currency: _to.iso_code, date: date
rescue
logger.warn "Exchange rate not found for #{_from.iso_code} to #{_to.iso_code} on #{date}"
nil
end end
def get_rate_series(from, to, date_range) def get_rate_series(from, to, date_range)
where(base_currency: from, converted_currency: to, date: date_range).order(:date) where(base_currency: from, converted_currency: to, date: date_range).order(:date)
end end
# TODO: Replace with generic provider
# See https://github.com/maybe-finance/maybe/pull/556
def fetch_rate_from_provider(from, to, date)
response = Faraday.get("https://api.synthfinance.com/rates/historical") do |req|
req.headers["Authorization"] = "Bearer #{ENV["SYNTH_API_KEY"]}"
req.params["date"] = date.to_s
req.params["from"] = from
req.params["to"] = to
end
if response.success?
rates = JSON.parse(response.body)
rates.dig("data", "rates", to)
else
nil
end
end
end end
end end

View file

@ -0,0 +1,24 @@
module ExchangeRate::Provided
extend ActiveSupport::Concern
include Providable
class_methods do
private
def fetch_rate_from_provider(from:, to:, date:)
response = exchange_rates_provider.fetch_exchange_rate \
from: Money::Currency.new(from).iso_code,
to: Money::Currency.new(to).iso_code,
date: date
if response.success?
ExchangeRate.new \
base_currency: from,
converted_currency: to,
rate: response.rate,
date: date
else
raise response.error
end
end
end
end

View file

@ -0,0 +1,18 @@
class Provider::Base
ProviderError = Class.new(StandardError)
TRANSIENT_NETWORK_ERRORS = [
Faraday::TimeoutError,
Faraday::ConnectionFailed,
Faraday::SSLError,
Faraday::ClientError,
Faraday::ServerError
]
class << self
def known_transient_errors
TRANSIENT_NETWORK_ERRORS + [ ProviderError ]
end
end
end

View file

@ -0,0 +1,51 @@
class Provider::Synth
include Retryable
def initialize(api_key = ENV["SYNTH_API_KEY"])
@api_key = api_key || ENV["SYNTH_API_KEY"]
end
def fetch_exchange_rate(from:, to:, date:)
retrying Provider::Base.known_transient_errors do |on_last_attempt|
response = Faraday.get("#{base_url}/rates/historical") do |req|
req.headers["Authorization"] = "Bearer #{api_key}"
req.params["date"] = date.to_s
req.params["from"] = from
req.params["to"] = to
end
if response.success?
ExchangeRateResponse.new \
rate: JSON.parse(response.body).dig("data", "rates", to),
success?: true,
raw_response: response
else
if on_last_attempt
ExchangeRateResponse.new \
success?: false,
error: build_error(response),
raw_response: response
else
raise build_error(response)
end
end
end
end
private
attr_reader :api_key
ExchangeRateResponse = Struct.new :rate, :success?, :error, :raw_response, keyword_init: true
def base_url
"https://api.synthfinance.com"
end
def build_error(response)
Provider::Base::ProviderError.new(<<~ERROR)
Failed to fetch exchange rate from #{self.class}
Status: #{response.status}
Body: #{response.body.inspect}
ERROR
end
end

View file

@ -26,7 +26,7 @@ class Money
# TODO: Replace with injected rate store # TODO: Replace with injected rate store
def exchange_to(other_currency, date = Date.current) def exchange_to(other_currency, date = Date.current)
return self if @currency == Money::Currency.new(other_currency) return self if @currency == Money::Currency.new(other_currency)
rate = ExchangeRate.get_rate(@currency, other_currency, date) rate = ExchangeRate.find_rate(from: @currency, to: other_currency, date: date)
return nil if rate.nil? return nil if rate.nil?
Money.new(@amount * rate.rate, other_currency) Money.new(@amount * rate.rate, other_currency)
end end

19
lib/retryable.rb Normal file
View file

@ -0,0 +1,19 @@
module Retryable
def retrying(retryable_errors = [], max_retries: 3)
attempts = 0
begin
on_last_attempt = attempts == max_retries - 1
yield on_last_attempt
rescue *retryable_errors => e
attempts += 1
if attempts < max_retries
retry
else
raise e
end
end
end
end

View file

@ -0,0 +1,27 @@
require "test_helper"
module ExchangeRateProviderInterfaceTest
extend ActiveSupport::Testing::Declarative
test "exchange rate provider interface" do
assert_respond_to @subject, :fetch_exchange_rate
end
test "exchange rate provider response contract" do
accounting_for_http_calls do
response = @subject.fetch_exchange_rate from: "USD", to: "MXN", date: Date.current
assert_respond_to response, :rate
assert_respond_to response, :success?
assert_respond_to response, :error
assert_respond_to response, :raw_response
end
end
private
def accounting_for_http_calls
VCR.use_cassette "synth_exchange_rate" do
yield
end
end
end

View file

@ -1,7 +1,42 @@
require "test_helper" require "test_helper"
class ExchangeRateTest < ActiveSupport::TestCase class ExchangeRateTest < ActiveSupport::TestCase
# test "the truth" do test "find rate in db" do
# assert true assert_equal exchange_rates(:day_29_ago_eur_to_usd),
# end ExchangeRate.find_rate_or_fetch(from: "EUR", to: "USD", date: 29.days.ago.to_date)
end
test "fetch rate from provider when it's not found in db" do
ExchangeRate
.expects(:fetch_rate_from_provider)
.returns(ExchangeRate.new(base_currency: "USD", converted_currency: "MXN", rate: 1.0, date: Date.current))
ExchangeRate.find_rate_or_fetch from: "USD", to: "MXN", date: Date.current
end
test "provided rates are saved to the db" do
VCR.use_cassette "synth_exchange_rate" do
assert_difference "ExchangeRate.count", 1 do
ExchangeRate.find_rate_or_fetch from: "USD", to: "MXN", date: Date.current
end
end
end
test "retrying, then raising on provider error" do
Faraday.expects(:get).returns(OpenStruct.new(success?: false)).times(3)
error = assert_raises Provider::Base::ProviderError do
ExchangeRate.find_rate_or_fetch from: "USD", to: "MXN", date: Date.current
end
assert_match "Failed to fetch exchange rate from Provider::Synth", error.message
end
test "retrying, then raising on network error" do
Faraday.expects(:get).raises(Faraday::TimeoutError).times(3)
assert_raises Faraday::TimeoutError do
ExchangeRate.find_rate_or_fetch from: "USD", to: "MXN", date: Date.current
end
end
end end

View file

@ -0,0 +1,9 @@
require "test_helper"
class Provider::SynthTest < ActiveSupport::TestCase
include ExchangeRateProviderInterfaceTest
setup do
@subject = Provider::Synth.new
end
end

View file

@ -1,6 +1,17 @@
ENV["RAILS_ENV"] ||= "test" ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment" require_relative "../config/environment"
require "rails/test_help" require "rails/test_help"
require "minitest/mock"
require "minitest/autorun"
require "mocha/minitest"
VCR.configure do |config|
config.cassette_library_dir = "test/vcr_cassettes"
config.hook_into :webmock
config.ignore_localhost = true
config.default_cassette_options = { erb: true }
config.filter_sensitive_data("<SYNTH_API_KEY>") { ENV["SYNTH_API_KEY"] }
end
module ActiveSupport module ActiveSupport
class TestCase class TestCase
@ -16,3 +27,5 @@ module ActiveSupport
end end
end end
end end
Dir[Rails.root.join("test", "interfaces", "**", "*.rb")].each { |f| require f }

View file

@ -0,0 +1,69 @@
---
http_interactions:
- request:
method: get
uri: https://api.synthfinance.com/rates/historical?date=<%= Date.current.to_s %>&from=USD&to=MXN
body:
encoding: US-ASCII
string: ''
headers:
User-Agent:
- Faraday v2.9.0
Authorization:
- Bearer <SYNTH_API_KEY>
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
- "*/*"
response:
status:
code: 200
message: OK
headers:
Date:
- Wed, 27 Mar 2024 02:54:11 GMT
Content-Type:
- application/json; charset=utf-8
Content-Length:
- '138'
Connection:
- keep-alive
Cf-Ray:
- 86ac182ad9ec7ce5-LAX
Cf-Cache-Status:
- DYNAMIC
Cache-Control:
- max-age=0, private, must-revalidate
Etag:
- W/"46780d3f34043bb3bc799b1efae62418"
Strict-Transport-Security:
- max-age=63072000; includeSubDomains
Vary:
- Accept-Encoding
Referrer-Policy:
- strict-origin-when-cross-origin
Rndr-Id:
- 3ca97b82-f963-43a3
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
- none
X-Render-Origin-Server:
- Render
X-Request-Id:
- 64731a8c-4cad-4e42-81c9-60b0d3634a0f
X-Runtime:
- '0.021432'
X-Xss-Protection:
- '0'
Server:
- cloudflare
Alt-Svc:
- h3=":443"; ma=86400
body:
encoding: ASCII-8BIT
string: '{"data":{"date":"<%= Date.current.to_s %>","source":"USD","rates":{"MXN":16.64663}},"meta":{"total_records":1,"credits_used":1,"credits_remaining":976}}'
recorded_at: Wed, 27 Mar 2024 02:54:11 GMT
recorded_with: VCR 6.2.0