218 lines
5.9 KiB
Ruby
218 lines
5.9 KiB
Ruby
|
|
# 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
|