mirror of
https://github.com/we-promise/sure.git
synced 2026-07-20 00:35:22 +00:00
feat(provider): add Frankfurter as an exchange-rate provider (#2640)
* feat(exchange-rates): add Frankfurter as an exchange-rate provider Frankfurter (frankfurter.dev) is a free, keyless FX rates API backed by ECB daily reference rates, with no published rate limit and no auth flow to maintain (unlike Yahoo Finance's reverse-engineered cookie/ crumb auth or TwelveData's fast-exhausting free tier). Follows the Provider::MoexPublic template: Faraday client with retry middleware, SslConfigurable for self-hosted CA support, a light RateLimitable throttle, and a FRANKFURTER_URL env escape hatch for self-hosters. Registered as exchange-rates-only (no security/stock data) and added to the hosting settings dropdown. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor(provider): switch Frankfurter to the v2 API v1 is explicitly marked "frozen" on Frankfurter's own root endpoint; v2 is "current" and covers 201 currencies across 84 central banks vs v1's ~30 ECB-only. Confirmed via the v2 OpenAPI spec and live requests: - Single-date lookups now use GET /rate/{base}/{quote}?date=..., which carries weekends/holidays forward server-side (a Saturday returns a real rate directly), so the provider no longer needs its own lookback-window logic. - Range lookups now use GET /rates?base=..."es=...&from=...&to=..., a flat array of { date, base, quote, rate } records (v2's shape) instead of v1's { "rates": { date => currencies } } hash. - Every calendar day in a range is present (v2 gapfills itself), rather than v1's omit-non-trading-days behavior. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(provider): sanitize currency codes before URL path interpolation from/to were only upcased before being interpolated directly into the URL path in fetch_exchange_rate (GET /rate/{from}/{to}). Low risk since currency codes come from validated internal sources, but adds cheap defense-in-depth: strip anything that isn't A-Z, matching the ISO 4217 format real currency codes always take. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
6db11708ea
commit
e6a0ca597b
151
app/models/provider/frankfurter.rb
Normal file
151
app/models/provider/frankfurter.rb
Normal file
@@ -0,0 +1,151 @@
|
||||
# Frankfurter (https://frankfurter.dev), a free, keyless, open-source FX rates
|
||||
# API backed by exchange rates blended across multiple central banks (ECB,
|
||||
# FED, BOC, etc). No auth, no key, no published rate limit, and self-hostable
|
||||
# (out of scope here, we just consume the public instance, with
|
||||
# FRANKFURTER_URL as an escape hatch for self-hosters later).
|
||||
#
|
||||
# This targets Frankfurter's v2 API (https://api.frankfurter.dev/v2), not v1.
|
||||
# Per Frankfurter's own root endpoint, v1 is status "frozen" (stable, no new
|
||||
# features) while v2 is status "current" (the actively developed version)
|
||||
# and covers far more currencies (201 across 84 central banks, vs v1's ~30
|
||||
# ECB-only). v2 also carries forward weekends/holidays server-side (a single
|
||||
# date lookup on a non-trading day returns the last known rate directly), so
|
||||
# unlike v1 this provider does not need its own lookback-window logic.
|
||||
class Provider::Frankfurter < Provider
|
||||
include ExchangeRateConcept, RateLimitable
|
||||
extend SslConfigurable
|
||||
|
||||
Error = Class.new(Provider::Error)
|
||||
RateLimitError = Class.new(Error)
|
||||
|
||||
# No published rate limit, but a light throttle is cheap insurance.
|
||||
MIN_REQUEST_INTERVAL = 0.15
|
||||
|
||||
def initialize
|
||||
# No API key required, public endpoint only.
|
||||
end
|
||||
|
||||
def healthy?
|
||||
with_provider_response do
|
||||
body = get_json("/currencies")
|
||||
raise Error, "Frankfurter currencies endpoint returned no data" if body.blank?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def usage
|
||||
with_provider_response do
|
||||
UsageData.new(used: nil, limit: nil, utilization: nil, plan: "Free (no key required)")
|
||||
end
|
||||
end
|
||||
|
||||
# GET /rate/{base}/{quote}?date=... -> { date:, base:, quote:, rate: }.
|
||||
# Frankfurter carries forward weekends/holidays itself, so the returned
|
||||
# date may differ from the requested one but is never simply missing.
|
||||
def fetch_exchange_rate(from:, to:, date:)
|
||||
from = sanitize_currency(from)
|
||||
to = sanitize_currency(to)
|
||||
|
||||
with_provider_response do
|
||||
if from == to
|
||||
Rate.new(date: date, from: from, to: to, rate: 1.0)
|
||||
else
|
||||
body = get_json("/rate/#{from}/#{to}", "date" => date.to_s)
|
||||
raise Error, "Unexpected Frankfurter response shape" unless body.is_a?(Hash) && body["rate"]
|
||||
|
||||
begin
|
||||
parsed_date = Date.parse(body["date"].to_s)
|
||||
rescue Date::Error => e
|
||||
raise Error, "Invalid date in Frankfurter response: #{e.message}"
|
||||
end
|
||||
|
||||
Rate.new(date: parsed_date, from: from, to: to, rate: body["rate"].to_f)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
|
||||
from = sanitize_currency(from)
|
||||
to = sanitize_currency(to)
|
||||
|
||||
with_provider_response do
|
||||
if from == to
|
||||
generate_same_currency_rates(from, to, start_date, end_date)
|
||||
else
|
||||
exchange_rates(from, to, start_date, end_date)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def max_history_days
|
||||
nil # Backed by central bank reference rates going back decades, no bounded window.
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# from/to are interpolated directly into the URL path in
|
||||
# fetch_exchange_rate (GET /rate/{from}/{to}), so strip anything that
|
||||
# isn't a letter before use - real ISO 4217 codes are always A-Z anyway.
|
||||
def sanitize_currency(code)
|
||||
code.to_s.upcase.gsub(/[^A-Z]/, "")
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV["FRANKFURTER_URL"].presence || "https://api.frankfurter.dev/v2"
|
||||
end
|
||||
|
||||
def get_json(path, params = {})
|
||||
throttle_request
|
||||
response = client.get("#{base_url}#{path}") do |req|
|
||||
params.each { |k, v| req.params[k] = v }
|
||||
end
|
||||
JSON.parse(response.body)
|
||||
rescue JSON::ParserError => e
|
||||
raise Error, "Invalid Frankfurter response: #{e.message}"
|
||||
end
|
||||
|
||||
def client
|
||||
@client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday|
|
||||
faraday.options.open_timeout = 5
|
||||
faraday.options.timeout = 20
|
||||
|
||||
faraday.request(:retry, {
|
||||
max: 3,
|
||||
interval: 0.5,
|
||||
interval_randomness: 0.5,
|
||||
backoff_factor: 2,
|
||||
exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [ Faraday::ConnectionFailed ]
|
||||
})
|
||||
|
||||
faraday.request :json
|
||||
faraday.response :raise_error
|
||||
faraday.headers["Accept"] = "application/json"
|
||||
end
|
||||
end
|
||||
|
||||
def generate_same_currency_rates(from, to, start_date, end_date)
|
||||
(start_date..end_date).map do |date|
|
||||
Rate.new(date: date, from: from, to: to, rate: 1.0)
|
||||
end
|
||||
end
|
||||
|
||||
# GET /rates?base=..."es=...&from=...&to=... -> a flat array of
|
||||
# { date:, base:, quote:, rate: } records, one per day in range (v2
|
||||
# carries forward weekends/holidays itself, so every calendar day in the
|
||||
# range is present, not just trading days).
|
||||
def exchange_rates(from, to, start_date, end_date)
|
||||
body = get_json("/rates", "base" => from, "quotes" => to, "from" => start_date.to_s, "to" => end_date.to_s)
|
||||
raise Error, "Unexpected Frankfurter response shape (expected an array)" unless body.is_a?(Array)
|
||||
|
||||
body.filter_map do |entry|
|
||||
next nil unless entry.is_a?(Hash) && entry["quote"] == to
|
||||
|
||||
rate_value = entry["rate"]
|
||||
next nil if rate_value.nil?
|
||||
|
||||
Rate.new(date: Date.parse(entry["date"].to_s), from: from, to: to, rate: rate_value.to_f)
|
||||
end.sort_by(&:date)
|
||||
rescue Date::Error => e
|
||||
raise Error, "Invalid date in Frankfurter response: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -145,6 +145,10 @@ class Provider::Registry
|
||||
Provider::MoexPublic.new
|
||||
end
|
||||
|
||||
def frankfurter
|
||||
Provider::Frankfurter.new
|
||||
end
|
||||
|
||||
def tinkoff_invest
|
||||
api_key = ENV["TINKOFF_INVEST_API_KEY"].presence || Setting.tinkoff_invest_api_key # pipelock:ignore
|
||||
|
||||
@@ -182,7 +186,7 @@ class Provider::Registry
|
||||
def available_providers
|
||||
case concept
|
||||
when :exchange_rates
|
||||
%i[twelve_data yahoo_finance moex_public]
|
||||
%i[twelve_data yahoo_finance moex_public frankfurter]
|
||||
when :securities
|
||||
%i[twelve_data yahoo_finance tiingo eodhd alpha_vantage mfapi binance_public moex_public tinkoff_invest]
|
||||
when :llm
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
[
|
||||
[t(".providers.twelve_data"), "twelve_data"],
|
||||
[t(".providers.yahoo_finance"), "yahoo_finance"],
|
||||
[t(".providers.moex_public"), "moex_public"]
|
||||
[t(".providers.moex_public"), "moex_public"],
|
||||
[t(".providers.frankfurter"), "frankfurter"]
|
||||
],
|
||||
{ label: t(".exchange_rate_provider_label") },
|
||||
{
|
||||
|
||||
@@ -56,6 +56,7 @@ en:
|
||||
binance_public: Binance
|
||||
moex_public: MOEX
|
||||
tinkoff_invest: T-Invest (T-Bank)
|
||||
frankfurter: Frankfurter
|
||||
assistant_settings:
|
||||
title: AI Assistant
|
||||
description: Choose how the chat assistant responds. Builtin uses your configured LLM provider directly. External delegates to a remote AI agent that can call back to Sure's financial tools via MCP.
|
||||
|
||||
245
test/models/provider/frankfurter_test.rb
Normal file
245
test/models/provider/frankfurter_test.rb
Normal file
@@ -0,0 +1,245 @@
|
||||
require "test_helper"
|
||||
|
||||
class Provider::FrankfurterTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@provider = Provider::Frankfurter.new
|
||||
@provider.stubs(:throttle_request)
|
||||
end
|
||||
|
||||
# ================================
|
||||
# Same-currency shortcut
|
||||
# ================================
|
||||
|
||||
test "fetch_exchange_rate returns 1.0 for the same currency without calling the API" do
|
||||
@provider.expects(:get_json).never
|
||||
|
||||
response = @provider.fetch_exchange_rate(from: "USD", to: "USD", date: Date.current)
|
||||
|
||||
assert response.success?
|
||||
assert_equal 1.0, response.data.rate
|
||||
assert_equal "USD", response.data.from
|
||||
assert_equal "USD", response.data.to
|
||||
end
|
||||
|
||||
test "fetch_exchange_rates returns 1.0 rates for every date in range for the same currency" do
|
||||
@provider.expects(:get_json).never
|
||||
start_date = Date.current - 3
|
||||
end_date = Date.current
|
||||
|
||||
response = @provider.fetch_exchange_rates(from: "EUR", to: "EUR", start_date: start_date, end_date: end_date)
|
||||
|
||||
assert response.success?
|
||||
assert_equal 4, response.data.size
|
||||
assert response.data.all? { |r| r.rate == 1.0 }
|
||||
end
|
||||
|
||||
test "fetch_exchange_rate treats mixed-case same currency as the same-currency shortcut" do
|
||||
@provider.expects(:get_json).never
|
||||
|
||||
response = @provider.fetch_exchange_rate(from: "usd", to: "USD", date: Date.current)
|
||||
|
||||
assert response.success?
|
||||
assert_equal 1.0, response.data.rate
|
||||
end
|
||||
|
||||
test "fetch_exchange_rate strips non-letter characters from currency codes before building the URL path" do
|
||||
date = Date.current - 5
|
||||
body = { "date" => date.to_s, "base" => "USD", "quote" => "INR", "rate" => 83.1 }
|
||||
@provider.expects(:get_json).with("/rate/USD/INR", has_entries("date" => date.to_s)).returns(body)
|
||||
|
||||
response = @provider.fetch_exchange_rate(from: "US/../D", to: "IN;R", date: date)
|
||||
|
||||
assert response.success?
|
||||
assert_equal "USD", response.data.from
|
||||
assert_equal "INR", response.data.to
|
||||
end
|
||||
|
||||
# ================================
|
||||
# fetch_exchange_rate (GET /rate/{base}/{quote})
|
||||
# ================================
|
||||
|
||||
test "fetch_exchange_rate returns the direct cross-rate for a real pair" do
|
||||
date = Date.current - 5
|
||||
stub_rate(from: "INR", to: "CAD", date: date, body: { "date" => date.to_s, "base" => "INR", "quote" => "CAD", "rate" => 0.01484 })
|
||||
|
||||
response = @provider.fetch_exchange_rate(from: "INR", to: "CAD", date: date)
|
||||
|
||||
assert response.success?
|
||||
assert_equal date, response.data.date
|
||||
assert_in_delta 0.01484, response.data.rate
|
||||
assert_equal "INR", response.data.from
|
||||
assert_equal "CAD", response.data.to
|
||||
end
|
||||
|
||||
test "fetch_exchange_rate matches a lowercase target currency against Frankfurter's uppercase response" do
|
||||
date = Date.current - 5
|
||||
stub_rate(from: "USD", to: "INR", date: date, body: { "date" => date.to_s, "base" => "USD", "quote" => "INR", "rate" => 83.1 })
|
||||
|
||||
response = @provider.fetch_exchange_rate(from: "usd", to: "inr", date: date)
|
||||
|
||||
assert response.success?
|
||||
assert_in_delta 83.1, response.data.rate
|
||||
assert_equal "USD", response.data.from
|
||||
assert_equal "INR", response.data.to
|
||||
end
|
||||
|
||||
test "fetch_exchange_rate uses whatever date Frankfurter's own carry-forward returns" do
|
||||
# v2 carries weekends/holidays forward server-side, so the response date
|
||||
# can legitimately differ from the requested one - we trust it as-is.
|
||||
requested_date = Date.current - 5
|
||||
carried_forward_date = requested_date - 2
|
||||
stub_rate(from: "USD", to: "INR", date: requested_date, body: { "date" => carried_forward_date.to_s, "base" => "USD", "quote" => "INR", "rate" => 91.0 })
|
||||
|
||||
response = @provider.fetch_exchange_rate(from: "USD", to: "INR", date: requested_date)
|
||||
|
||||
assert response.success?
|
||||
assert_equal carried_forward_date, response.data.date
|
||||
assert_in_delta 91.0, response.data.rate
|
||||
end
|
||||
|
||||
test "fetch_exchange_rate fails without raising when the API call errors" do
|
||||
@provider.stubs(:get_json).raises(StandardError.new("boom"))
|
||||
|
||||
response = @provider.fetch_exchange_rate(from: "USD", to: "INR", date: Date.current)
|
||||
|
||||
assert_not response.success?
|
||||
assert_instance_of Provider::Frankfurter::Error, response.error
|
||||
end
|
||||
|
||||
test "fetch_exchange_rate fails without raising when the response is missing a rate" do
|
||||
date = Date.current - 5
|
||||
stub_rate(from: "USD", to: "INR", date: date, body: { "date" => date.to_s, "base" => "USD", "quote" => "INR" })
|
||||
|
||||
response = @provider.fetch_exchange_rate(from: "USD", to: "INR", date: date)
|
||||
|
||||
assert_not response.success?
|
||||
assert_instance_of Provider::Frankfurter::Error, response.error
|
||||
end
|
||||
|
||||
# ================================
|
||||
# fetch_exchange_rates (GET /rates)
|
||||
# ================================
|
||||
|
||||
test "fetch_exchange_rates returns a sorted range of rates" do
|
||||
start_date = Date.current - 5
|
||||
end_date = Date.current - 1
|
||||
body = [
|
||||
{ "date" => start_date.to_s, "base" => "INR", "quote" => "CAD", "rate" => 0.0148 },
|
||||
{ "date" => (start_date + 1).to_s, "base" => "INR", "quote" => "CAD", "rate" => 0.0149 },
|
||||
{ "date" => end_date.to_s, "base" => "INR", "quote" => "CAD", "rate" => 0.0150 }
|
||||
]
|
||||
stub_range(from: "INR", to: "CAD", start_date: start_date, end_date: end_date, body: body)
|
||||
|
||||
response = @provider.fetch_exchange_rates(from: "INR", to: "CAD", start_date: start_date, end_date: end_date)
|
||||
|
||||
assert response.success?
|
||||
assert_equal 3, response.data.size
|
||||
assert_equal response.data.map(&:date), response.data.map(&:date).sort
|
||||
assert_equal start_date, response.data.first.date
|
||||
assert_equal end_date, response.data.last.date
|
||||
end
|
||||
|
||||
test "fetch_exchange_rates includes every calendar day (v2 carries weekends/holidays forward itself)" do
|
||||
start_date = Date.new(2024, 3, 16) # Saturday
|
||||
end_date = Date.new(2024, 3, 17) # Sunday
|
||||
body = [
|
||||
{ "date" => "2024-03-16", "base" => "INR", "quote" => "CAD", "rate" => 0.01631 },
|
||||
{ "date" => "2024-03-17", "base" => "INR", "quote" => "CAD", "rate" => 0.01631 }
|
||||
]
|
||||
stub_range(from: "INR", to: "CAD", start_date: start_date, end_date: end_date, body: body)
|
||||
|
||||
response = @provider.fetch_exchange_rates(from: "INR", to: "CAD", start_date: start_date, end_date: end_date)
|
||||
|
||||
assert response.success?
|
||||
assert_equal 2, response.data.size
|
||||
end
|
||||
|
||||
test "fetch_exchange_rates ignores entries for a different quote currency" do
|
||||
start_date = Date.current - 3
|
||||
end_date = Date.current - 1
|
||||
body = [
|
||||
{ "date" => start_date.to_s, "base" => "INR", "quote" => "CAD", "rate" => 0.0148 },
|
||||
{ "date" => start_date.to_s, "base" => "INR", "quote" => "USD", "rate" => 0.012 }
|
||||
]
|
||||
stub_range(from: "INR", to: "CAD", start_date: start_date, end_date: end_date, body: body)
|
||||
|
||||
response = @provider.fetch_exchange_rates(from: "INR", to: "CAD", start_date: start_date, end_date: end_date)
|
||||
|
||||
assert response.success?
|
||||
assert_equal 1, response.data.size
|
||||
assert_equal "CAD", response.data.first.to
|
||||
end
|
||||
|
||||
# ================================
|
||||
# Error handling
|
||||
# ================================
|
||||
|
||||
test "fetch_exchange_rates fails without raising when the response is not an array" do
|
||||
@provider.stubs(:get_json).returns({ "status" => 422, "message" => "invalid currency" })
|
||||
|
||||
response = @provider.fetch_exchange_rates(
|
||||
from: "INR", to: "CAD", start_date: Date.current - 5, end_date: Date.current - 1
|
||||
)
|
||||
|
||||
assert_not response.success?
|
||||
assert_instance_of Provider::Frankfurter::Error, response.error
|
||||
end
|
||||
|
||||
test "fetch_exchange_rates fails without raising on a network error" do
|
||||
@provider.stubs(:get_json).raises(Faraday::ConnectionFailed.new("connection refused"))
|
||||
|
||||
response = @provider.fetch_exchange_rates(
|
||||
from: "INR", to: "CAD", start_date: Date.current - 5, end_date: Date.current - 1
|
||||
)
|
||||
|
||||
assert_not response.success?
|
||||
assert_instance_of Provider::Frankfurter::Error, response.error
|
||||
end
|
||||
|
||||
# ================================
|
||||
# healthy? / usage / max_history_days
|
||||
# ================================
|
||||
|
||||
test "healthy? returns true when the currencies endpoint responds" do
|
||||
@provider.stubs(:get_json).with("/currencies").returns([ { "iso_code" => "USD", "name" => "United States Dollar" } ])
|
||||
|
||||
response = @provider.healthy?
|
||||
|
||||
assert response.success?
|
||||
assert response.data
|
||||
end
|
||||
|
||||
test "healthy? fails when the currencies endpoint returns nothing" do
|
||||
@provider.stubs(:get_json).with("/currencies").returns([])
|
||||
|
||||
response = @provider.healthy?
|
||||
|
||||
assert_not response.success?
|
||||
end
|
||||
|
||||
test "usage reports a free, keyless plan" do
|
||||
response = @provider.usage
|
||||
|
||||
assert response.success?
|
||||
assert_equal "Free (no key required)", response.data.plan
|
||||
assert_nil response.data.limit
|
||||
end
|
||||
|
||||
test "max_history_days is nil (unbounded)" do
|
||||
assert_nil @provider.max_history_days
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stub_rate(from:, to:, date:, body:)
|
||||
@provider.stubs(:get_json)
|
||||
.with("/rate/#{from}/#{to}", has_entries("date" => date.to_s))
|
||||
.returns(body)
|
||||
end
|
||||
|
||||
def stub_range(from:, to:, start_date:, end_date:, body:)
|
||||
@provider.stubs(:get_json)
|
||||
.with("/rates", has_entries("base" => from, "quotes" => to, "from" => start_date.to_s, "to" => end_date.to_s))
|
||||
.returns(body)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user