# 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