frankfurter/lib/versions/v2.rb

218 lines
5.9 KiB
Ruby
Raw Normal View History

2026-05-21 01:38:14 -06:00
# frozen_string_literal: true
require "csv"
require "currency"
require "oj"
require "provider"
require "roda"
require "versions/v2/rate_query"
module Versions
class V2 < Roda
ROOT_PAYLOAD = {
version: "v2",
status: "current",
openapi: "/v2/openapi.json",
docs: "https://frankfurter.dev",
}.freeze
plugin :json,
content_type: "application/json; charset=utf-8",
serializer: ->(o) { Oj.dump(o, mode: :compat) }
plugin :type_routing,
types: { csv: "text/csv" }
plugin :streaming
plugin :caching
plugin :indifferent_params
plugin :halt
plugin :status_handler
status_handler(404) { { status: 404, message: "not found" } }
plugin :error_handler do |error|
status = case error
when RateQuery::ValidationError then 422
else 500
end
request.halt(status, { status:, message: error.message })
end
route do |r|
response.cache_control(public: true, max_age: 86400)
r.is { ROOT_PAYLOAD }
r.root { ROOT_PAYLOAD }
r.on("rates") do
r.get do
query = RateQuery.new(r.params)
r.etag(query.cache_key)
r.csv do
if query.range?
first, rest = eager_split(query)
response["Content-Type"] = "text/csv"
headers = csv_headers(query)
stream do |out|
out << CSV.generate_line(headers)
if first
out << CSV.generate_line(headers.map { |k| csv_value(first[k]) })
rest.each do |record|
out << CSV.generate_line(headers.map { |k| csv_value(record[k]) })
end
end
end
else
to_csv(query.to_a, query)
end
end
if ndjson?(r)
first, rest = eager_split(query)
response["Vary"] = "Accept"
response["Content-Type"] = "application/x-ndjson"
stream do |out|
if first
out << Oj.dump(first, mode: :compat)
out << "\n"
rest.each do |record|
out << Oj.dump(record, mode: :compat)
out << "\n"
end
end
end
elsif query.range?
first, rest = eager_split(query)
response["Content-Type"] = "application/json; charset=utf-8"
stream do |out|
out << "["
if first
out << Oj.dump(first, mode: :compat)
rest.each do |record|
out << ","
out << Oj.dump(record, mode: :compat)
end
end
out << "]"
end
else
query.to_a
end
end
end
r.on("rate", String, String) do |base_currency, quote_currency|
r.get do
params = r.params.merge("base" => base_currency.upcase, "quotes" => quote_currency.upcase)
query = RateQuery.new(params)
result = query.to_a.first || r.halt(404)
result
end
end
r.csv { r.halt(406) }
r.on("currency", String) do |code|
r.get do
found = Currency.find(code)
found ? found.to_h_with_providers : request.halt(404)
end
end
r.on("currencies") do
r.get do
currencies(r.params)
end
end
r.is("providers") do
r.get do
response.cache_control(public: true, max_age: 3600)
providers
end
end
end
private
# Pull the first record before streaming so deterministic data errors raise
# in the route block (caught by error_handler) instead of mid-stream after
# response headers — including Cache-Control — have been flushed.
#
# `rest` continues draining the same fiber-backed enumerator via #next;
# iterating the enumerator with #each instead would restart it from the
# beginning and re-emit the already-consumed first record.
def eager_split(query)
enum = query.each
first = enum.next
rest = Enumerator.new do |y|
loop { y << enum.next }
end
[first, rest]
rescue StopIteration
[nil, [].each]
end
def ndjson?(request)
accept = request.env["HTTP_ACCEPT"] || ""
accept.include?("application/x-ndjson")
end
def to_csv(records, query = nil)
CSV.generate do |csv|
return csv.string if records.empty?
headers = query ? csv_headers(query) : records.first.keys
csv << headers
records.each { |r| csv << headers.map { |k| csv_value(r[k]) } }
end
end
def csv_headers(query)
base = [:date, :base, :quote, :rate]
query.expand_providers? ? base + [:providers] : base
end
def csv_value(value)
return value unless value.is_a?(Array)
value.map { |p| p[:excluded] ? "#{p[:key]}:#{p[:rate]}*" : "#{p[:key]}:#{p[:rate]}" }.join("|")
end
def currencies(params)
provider_keys = params["providers"]&.upcase&.split(",")
records = if provider_keys
Currency.with_providers(provider_keys).all
elsif params["scope"] == "all"
Currency.all
else
Currency.active
end
records.map(&:to_h)
end
def providers
Provider.eager(:currency_coverages).all.sort_by(&:key).filter_map do |provider|
next if provider.currency_coverages.empty?
{
key: provider.key,
name: provider.name,
country_code: provider.country_code,
rate_type: provider.rate_type,
pivot_currency: provider.pivot_currency,
data_url: provider.data_url,
terms_url: provider.terms_url,
start_date: provider.start_date,
end_date: provider.end_date,
publishes_missed: provider.publishes_missed,
currencies: provider.currency_coverages.map(&:iso_code).sort,
}
end
end
end
end