Repack app

I'm moving my company's server to a private location now that I have sold the domain. While prepping for this, I've done some cleanup and also threw in changes I had lingering on my hard drive.

- Run a single database query instead of two
- Fold the gem into the app and use Ox instead of REXML
- Simplify error handling logic
- Relax throttling
This commit is contained in:
Hakan Ensari 2018-03-08 01:05:19 +00:00
parent 9d0d22e504
commit cfbb4ac4ac
55 changed files with 441 additions and 583 deletions

View File

@ -1,5 +1,5 @@
APP_ENV=production
HTTPS_METHOD=noredirect HTTPS_METHOD=noredirect
LETSENCRYPT_EMAIL=foo@bar.com LETSENCRYPT_EMAIL=foo@bar.com
LETSENCRYPT_HOST=example.com,www.example.com LETSENCRYPT_HOST=example.com,www.example.com
RACK_ENV=production
VIRTUAL_HOST=example.com,www.example.com VIRTUAL_HOST=example.com,www.example.com

View File

@ -1,10 +1,11 @@
before_install: language: ruby
- gem update --system sudo: false
- gem install bundler
- psql -c 'create database fixer_test;' -U postgres
- cd app
env:
- RACK_ENV=test
rvm: rvm:
- 2.5.0 - 2.5.0
sudo: false env:
- APP_ENV=test
bundler_args: "--without development"
before_install:
- "psql -c 'create database fixer_test;' -U postgres"

View File

@ -4,8 +4,8 @@ source 'http://rubygems.org'
ruby '2.5.0' ruby '2.5.0'
gem 'fixer'
gem 'oj' gem 'oj'
gem 'ox'
gem 'rack-cors' gem 'rack-cors'
gem 'rake' gem 'rake'
gem 'rufus-scheduler' gem 'rufus-scheduler'
@ -14,8 +14,14 @@ gem 'sinatra'
gem 'unicorn' gem 'unicorn'
group :development do group :development do
gem 'pry'
end
group :test do
gem 'minitest' gem 'minitest'
gem 'minitest-around' gem 'minitest-around'
gem 'rack-test' gem 'rack-test'
gem 'rubocop' gem 'rubocop'
gem 'vcr'
gem 'webmock'
end end

View File

@ -1,33 +1,44 @@
GEM GEM
remote: http://rubygems.org/ remote: http://rubygems.org/
specs: specs:
ast (2.3.0) addressable (2.5.2)
et-orbi (1.0.8) public_suffix (>= 2.0.2, < 4.0)
ast (2.4.0)
coderay (1.1.2)
crack (0.4.3)
safe_yaml (~> 1.0.0)
et-orbi (1.0.9)
tzinfo tzinfo
fixer (1.0.0) hashdiff (0.3.7)
kgio (2.11.1) kgio (2.11.2)
minitest (5.11.1) method_source (0.9.0)
minitest-around (0.4.0) minitest (5.11.3)
minitest-around (0.4.1)
minitest (~> 5.0) minitest (~> 5.0)
mustermann (1.0.1) mustermann (1.0.2)
oj (3.3.10) oj (3.5.0)
ox (2.8.4)
parallel (1.12.1) parallel (1.12.1)
parser (2.4.0.2) parser (2.5.0.3)
ast (~> 2.3) ast (~> 2.4.0)
pg (0.21.0) pg (1.0.0)
powerpack (0.1.1) powerpack (0.1.1)
rack (2.0.3) pry (0.11.3)
coderay (~> 1.1.0)
method_source (~> 0.9.0)
public_suffix (3.0.2)
rack (2.0.4)
rack-cors (1.0.2) rack-cors (1.0.2)
rack-protection (2.0.0) rack-protection (2.0.1)
rack rack
rack-test (0.8.2) rack-test (0.8.3)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rainbow (3.0.0) rainbow (3.0.0)
raindrops (0.19.0) raindrops (0.19.0)
rake (12.3.0) rake (12.3.0)
rubocop (0.52.1) rubocop (0.53.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 2.4.0.2, < 3.0) parser (>= 2.5)
powerpack (~> 0.1) powerpack (~> 0.1)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
@ -35,32 +46,39 @@ GEM
ruby-progressbar (1.9.0) ruby-progressbar (1.9.0)
rufus-scheduler (3.4.2) rufus-scheduler (3.4.2)
et-orbi (~> 1.0) et-orbi (~> 1.0)
sequel (5.4.0) safe_yaml (1.0.4)
sequel (5.6.0)
sequel_pg (1.8.1) sequel_pg (1.8.1)
pg (>= 0.18.0) pg (>= 0.18.0)
sequel (>= 4.34.0) sequel (>= 4.34.0)
sinatra (2.0.0) sinatra (2.0.1)
mustermann (~> 1.0) mustermann (~> 1.0)
rack (~> 2.0) rack (~> 2.0)
rack-protection (= 2.0.0) rack-protection (= 2.0.1)
tilt (~> 2.0) tilt (~> 2.0)
thread_safe (0.3.6) thread_safe (0.3.6)
tilt (2.0.8) tilt (2.0.8)
tzinfo (1.2.4) tzinfo (1.2.5)
thread_safe (~> 0.1) thread_safe (~> 0.1)
unicode-display_width (1.3.0) unicode-display_width (1.3.0)
unicorn (5.4.0) unicorn (5.4.0)
kgio (~> 2.6) kgio (~> 2.6)
raindrops (~> 0.7) raindrops (~> 0.7)
vcr (4.0.0)
webmock (3.3.0)
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff
PLATFORMS PLATFORMS
ruby ruby
DEPENDENCIES DEPENDENCIES
fixer
minitest minitest
minitest-around minitest-around
oj oj
ox
pry
rack-cors rack-cors
rack-test rack-test
rake rake
@ -69,6 +87,8 @@ DEPENDENCIES
sequel_pg sequel_pg
sinatra sinatra
unicorn unicorn
vcr
webmock
RUBY VERSION RUBY VERSION
ruby 2.5.0p0 ruby 2.5.0p0

View File

@ -4,8 +4,6 @@
Fixer is a free API for current and historical foreign exchange rates [published by the European Central Bank](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html). Fixer is a free API for current and historical foreign exchange rates [published by the European Central Bank](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html).
A public instance of the API lives at [https://api.fixer.io](https://api.fixer.io). Alternatively, you can run privately with the provided [Docker image](https://hub.docker.com/r/hakanensari/fixer/).
Rates are updated around 4PM CET every working day. Rates are updated around 4PM CET every working day.
## Usage ## Usage
@ -22,16 +20,22 @@ Get historical rates for any day since 1999.
GET /2000-01-03 GET /2000-01-03
``` ```
Rates are quoted against the Euro by default. Quote against a different currency by setting the base parameter in your request. Rates quote against the Euro by default. Quote against a different currency.
```http ```http
GET /latest?base=USD GET /latest?from=USD
``` ```
Request specific exchange rates by setting the symbols parameter. Request specific exchange rates.
```http ```http
GET /latest?symbols=USD,GBP GET /latest?to=GBP
```
Change the amount requested.
```http
GET /latest?amount=100
``` ```
The primary use case is client side. For instance, with [money.js](https://openexchangerates.github.io/money.js/) in the browser The primary use case is client side. For instance, with [money.js](https://openexchangerates.github.io/money.js/) in the browser
@ -42,7 +46,7 @@ let demo = () => {
alert("£1 = $" + rate.toFixed(4)) alert("£1 = $" + rate.toFixed(4))
} }
fetch('https://api.fixer.io/latest') fetch('https://api.example.com/latest')
.then((resp) => resp.json()) .then((resp) => resp.json())
.then((data) => fx.rates = data.rates) .then((data) => fx.rates = data.rates)
.then(demo) .then(demo)
@ -50,8 +54,6 @@ fetch('https://api.fixer.io/latest')
## Installation ## Installation
I have included a sample Docker Compose configuration in the repo.
To build locally, type To build locally, type
```bash ```bash
@ -64,7 +66,7 @@ Now you can access the API at
http://localhost:8080 http://localhost:8080
``` ```
In production, create a [`.env`](.env.example) file in the project root and run with In production, create a [`.env`](.env.example) file and run with
```bash ```bash
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d

View File

@ -1,25 +0,0 @@
# frozen_string_literal: true
class Currency < Sequel::Model
dataset_module do
def recent
order(Sequel.desc(:date))
end
def before(value)
where { date <= value }
end
def current_date
recent.first&.date
end
def current_date_before(value)
before(value).current_date
end
end
def to_h
{ iso_code => rate }
end
end

View File

@ -1,80 +0,0 @@
# frozen_string_literal: true
require 'currency'
class Quote
Invalid = Class.new(StandardError)
DEFAULT_AMOUNT = 1
DEFAULT_BASE = 'EUR'
attr_reader :amount, :base, :date, :symbols
def initialize(params = {})
self.amount = params['amount']
self.base = params.values_at(:base, :from).compact.first
self.symbols = params.values_at(:symbols, :to).compact.first
self.date = params[:date]
end
def rates
@rates ||= find_rates
end
def attributes
{ base: base, date: date, rates: rates }
end
alias to_h attributes
private
attr_writer :symbols
def amount=(value)
@amount = (value || DEFAULT_AMOUNT).to_f
raise Invalid, 'Invalid amount' if @amount.zero?
end
def base=(value)
@base = value&.upcase || DEFAULT_BASE
end
def date=(value)
@date = value ? Currency.current_date_before(value) : Currency.current_date
raise Invalid, 'Date too old' unless @date
rescue Sequel::DatabaseError => ex
raise Invalid, 'Invalid date' if ex.wrapped_exception.is_a?(PG::DataException)
raise
end
def find_rates
rates = quoted_against_default_base? ? find_default_rates : find_rebased_rates
symbols ? rates.keep_if { |k, _| symbols.include?(k) } : rates
end
def quoted_against_default_base?
base == DEFAULT_BASE
end
def find_default_rates
Currency.where(date: date).order(:iso_code).reduce({}) do |rates, currency|
rates.update(Hash[currency.to_h.map { |k, v| [k, round_rate(v * amount)] }])
end
end
def find_rebased_rates
rates = find_default_rates
denominator = rates.update(DEFAULT_BASE => amount).delete(base)
raise Invalid, 'Invalid base' unless denominator
rates
.map { |(iso_code, rate)| [iso_code, round_rate(amount * rate / denominator)] }
.sort_by(&:first)
.to_h
end
# I'm mimicking the apparent convention of the ECB here.
def round_rate(rate)
Float(format('%.5g', rate))
end
end

View File

@ -1,35 +0,0 @@
# frozen_string_literal: true
require_relative 'helper'
require 'currency'
describe Currency do
around do |test|
Currency.db.transaction do
test.call
raise Sequel::Rollback
end
end
before do
Currency.dataset.delete
@first = Currency.create(date: '2014-01-01')
@last = Currency.create(date: '2015-01-01')
end
it 'returns current date' do
Currency.current_date.must_equal @last.date
end
it 'returns current date before given date' do
Currency.current_date_before(@last.date - 1).must_equal @first.date
end
it 'returns nil if there is no current date before given date' do
Currency.current_date_before(@first.date - 1).must_be_nil
end
it 'casts to hash' do
@first.to_h.must_be_kind_of Hash
end
end

View File

@ -1,5 +0,0 @@
# frozen_string_literal: true
require './config/environment'
require 'minitest/autorun'
require 'minitest/around/spec'

View File

@ -1,104 +0,0 @@
# frozen_string_literal: true
require_relative 'helper'
require 'quote'
describe Quote do
def stub_rates(default_rates = {})
quote.stub :find_default_rates, default_rates do
yield quote
end
end
describe 'by default' do
let(:quote) { Quote.new }
it 'quotes rates against the euro' do
stub_rates 'USD' => 1.25 do |quote|
rate = quote.rates['USD']
rate.must_equal 1.25
end
end
it 'does not quote the euro' do
stub_rates do |quote|
quote.rates.keys.wont_include 'EUR'
end
end
it 'casts to hash' do
stub_rates do |quote|
%i[base date rates].each do |key|
quote.to_h.keys.must_include key
end
end
end
end
describe 'when given a custom base' do
let(:quote) { Quote.new(base: 'FOO') }
it 'quotes rates against that currency' do
stub_rates 'FOO' => 2 do |quote|
quote.rates.keys.must_include 'EUR'
end
end
it 'does not quote the base currency' do
stub_rates 'FOO' => 2 do |quote|
quote.rates.keys.wont_include 'FOO'
end
end
it 'rounds to five significant digits' do
stub_rates 'FOO' => 0.6995 do |quote|
rate = quote.rates['EUR']
rate.must_equal 1.4296
end
end
it 'returns the currencies sorted' do
stub_rates 'BAZ' => 1.5, 'FOO' => 2, 'BAR' => 3, 'QUX' => 4 do |quote|
quote.rates.keys.must_equal quote.rates.keys.sort
end
end
end
describe 'when given an invalid base' do
let(:quote) { Quote.new(base: 'FOO') }
it 'raises validation error' do
proc { quote.to_h }.must_raise Quote::Invalid
end
end
describe 'when given a date that is too old' do
let(:quote) { Quote.new(date: '1900-01-01') }
it 'raises validation error' do
Currency.stub :current_date_before, nil do
proc { quote }.must_raise Quote::Invalid
end
end
end
describe 'when given a bad date' do
let(:quote) { Quote.new(date: '2000-31-01') }
it 'raises validation error' do
proc { quote }.must_raise Quote::Invalid
end
end
describe 'when given custom symbols' do
let(:quote) { Quote.new(symbols: 'FOO,BAR') }
it 'quotes rates only for given symbols' do
stub_rates 'FOO' => 1, 'BAR' => 2, 'BAZ' => 3 do |quote|
quote.rates.keys.must_include 'FOO'
quote.rates.keys.must_include 'BAR'
quote.rates.keys.wont_include 'BAZ'
end
end
end
end

View File

@ -6,7 +6,7 @@ require 'pathname'
module App module App
class << self class << self
def env def env
ENV['RACK_ENV'] || 'development' ENV['APP_ENV'] || 'development'
end end
def root def root

View File

@ -1,15 +1,13 @@
version: '3' version: '3'
services: services:
web: web:
build: build: .
context: ./app
environment: environment:
RACK_ENV: development APP_ENV: development
VIRTUAL_HOST: localhost VIRTUAL_HOST: localhost
ports: ports:
- "8080:8080" - "8080:8080"
scheduler: scheduler:
build: build: .
context: ./app
environment: environment:
RACK_ENV: development APP_ENV: development

View File

@ -1,5 +0,0 @@
AllCops:
TargetRubyVersion: 2.5
Metrics/BlockLength:
Exclude:
- 'spec/**/*'

View File

@ -1,4 +0,0 @@
# frozen_string_literal: true
source 'https://rubygems.org'
gemspec

View File

@ -1,52 +0,0 @@
PATH
remote: .
specs:
fixer (1.0.0)
GEM
remote: https://rubygems.org/
specs:
addressable (2.5.2)
public_suffix (>= 2.0.2, < 4.0)
ast (2.3.0)
crack (0.4.3)
safe_yaml (~> 1.0.0)
hashdiff (0.3.7)
minitest (5.10.3)
parallel (1.12.1)
parser (2.4.0.2)
ast (~> 2.3)
powerpack (0.1.1)
public_suffix (3.0.1)
rainbow (3.0.0)
rake (12.3.0)
rubocop (0.52.1)
parallel (~> 1.10)
parser (>= 2.4.0.2, < 3.0)
powerpack (~> 0.1)
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.0, >= 1.0.1)
ruby-progressbar (1.9.0)
safe_yaml (1.0.4)
unicode-display_width (1.3.0)
vcr (4.0.0)
webmock (3.1.1)
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff
PLATFORMS
ruby
DEPENDENCIES
bundler
fixer!
minitest
rake
rubocop
vcr
webmock
BUNDLED WITH
1.16.1

View File

@ -1,9 +0,0 @@
# Fixer
Fixer wraps the XML feeds of Euro foreign exchange reference provided by the European Central Bank:
```ruby
Fixer.current
Fixer.ninety_days
Fixer.historical
```

View File

@ -1,14 +0,0 @@
# frozen_string_literal: true
require 'bundler/gem_tasks'
require 'rake/testtask'
require 'rubocop/rake_task'
Rake::TestTask.new do |t|
t.ruby_opts += ['-W1']
t.pattern = 'spec/**/*_spec.rb'
end
RuboCop::RakeTask.new
task default: %i[test rubocop]

View File

@ -1,26 +0,0 @@
# frozen_string_literal: true
lib = File.expand_path('../lib', __FILE__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'fixer/version'
Gem::Specification.new do |spec|
spec.name = 'fixer'
spec.version = Fixer::VERSION
spec.author = ['Hakan Ensari']
spec.email = ['hakanensari@gmail.com']
spec.summary = <<-SUMMARY
A wrapper to the exchange rate feeds of the European Central Bank
SUMMARY
spec.homepage = 'https://github.com/hakanensari/fixer'
spec.license = 'MIT'
spec.files = Dir.glob('lib/**/*') + %w[README.md]
spec.require_paths = ['lib']
spec.add_development_dependency 'bundler'
spec.add_development_dependency 'minitest'
spec.add_development_dependency 'rake'
spec.add_development_dependency 'rubocop'
spec.add_development_dependency 'vcr'
spec.add_development_dependency 'webmock'
end

View File

@ -1,17 +0,0 @@
# frozen_string_literal: true
require 'fixer/feed'
# ECB exchange rate datasets
#
# The three available short hands here are `.current`, `.ninety_days`, and
# `.historical`.
module Fixer
class << self
Feed::SCOPES.each_key do |scope|
define_method(scope) do
Feed.new(scope)
end
end
end
end

View File

@ -1,48 +0,0 @@
# frozen_string_literal: true
require 'net/http'
require 'rexml/document'
module Fixer
# Wraps ECB's data feed
class Feed
include Enumerable
SCOPES = {
current: 'daily',
ninety_days: 'hist-90d',
historical: 'hist'
}.freeze
def initialize(scope = :current)
@scope = SCOPES.fetch(scope) { raise ArgumentError }
end
def each
REXML::XPath.each(document, '/gesmes:Envelope/Cube/Cube[@time]') do |day|
date = Date.parse(day.attribute('time').value)
REXML::XPath.each(day, './Cube') do |currency|
yield(
date: date,
iso_code: currency.attribute('currency').value,
rate: Float(currency.attribute('rate').value)
)
end
end
end
private
def document
REXML::Document.new(xml)
end
def xml
Net::HTTP.get(url)
end
def url
URI("http://www.ecb.europa.eu/stats/eurofxref/eurofxref-#{@scope}.xml")
end
end
end

View File

@ -1,5 +0,0 @@
# frozen_string_literal: true
module Fixer
VERSION = '1.0.0'
end

View File

@ -1,34 +0,0 @@
# frozen_string_literal: true
require_relative 'helper'
describe Fixer do
before do
@mock = MiniTest::Mock.new
end
after do
@mock.verify
end
it 'returns current rates' do
@mock.expect(:call, nil, [:current])
Fixer::Feed.stub(:new, @mock) do
Fixer.current
end
end
it 'returns ninety-day rates' do
@mock.expect(:call, nil, [:ninety_days])
Fixer::Feed.stub(:new, @mock) do
Fixer.ninety_days
end
end
it 'returns historical rates' do
@mock.expect(:call, nil, [:historical])
Fixer::Feed.stub(:new, @mock) do
Fixer.historical
end
end
end

View File

@ -3,7 +3,9 @@
require 'oj' require 'oj'
require 'sinatra' require 'sinatra'
require 'rack/cors' require 'rack/cors'
require 'quote'
require 'query'
require 'quotation'
use Rack::Cors do use Rack::Cors do
allow do allow do
@ -20,13 +22,20 @@ configure :production do
disable :dump_errors disable :dump_errors
end end
configure :test do
set :raise_errors, false
end
helpers do helpers do
def quote def quotation
@quote ||= Quote.new(params) @quotation ||= begin
query = Query.new(params)
Quotation.new(query.to_h)
end
end end
def jsonp(data) def jsonp(data)
json = encode_json(data) json = Oj.dump(data, mode: :compat)
callback = params.delete('callback') callback = params.delete('callback')
if callback if callback
content_type :js content_type :js
@ -36,10 +45,6 @@ helpers do
json json
end end
end end
def encode_json(data)
Oj.dump(data, mode: :compat)
end
end end
options '*' do options '*' do
@ -52,23 +57,23 @@ get '*' do
end end
get '/' do get '/' do
jsonp details: 'http://fixer.io' jsonp source: 'https://github.com/hakanensari/fixer'
end end
get '/latest' do get '/latest' do
last_modified quote.date last_modified quotation.date
jsonp quote.to_h jsonp quotation.quote
end end
get '/(?<date>\d{4}-\d{2}-\d{2})', mustermann_opts: { type: :regexp } do get '/(?<date>\d{4}-\d{2}-\d{2})', mustermann_opts: { type: :regexp } do
last_modified quote.date last_modified quotation.date
jsonp quote.to_h jsonp quotation.quote
end end
not_found do not_found do
halt 404, encode_json(error: 'Not found') halt 404
end end
error Quote::Invalid do |ex| error do
halt 422, encode_json(error: ex.message) halt 422
end end

View File

@ -1,20 +1,19 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'currency' require 'currency'
require 'fixer' require 'bank/feed'
module Bank module Bank
def self.fetch_all_rates! def self.fetch_all_rates!
Currency.db.transaction do Currency.db.transaction do
Currency.dataset.delete Currency.dataset.delete
data = Fixer.historical Currency.multi_insert(Feed.historical.to_a)
Currency.multi_insert(data.to_a)
end end
end end
def self.fetch_current_rates! def self.fetch_current_rates!
Currency.db.transaction do Currency.db.transaction do
Fixer.current.each do |hsh| Feed.current.each do |hsh|
Currency.find_or_create(hsh) Currency.find_or_create(hsh)
end end
end end

53
lib/bank/feed.rb Normal file
View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
require 'net/http'
require 'ox'
module Bank
class Feed
include Enumerable
def self.current
new('daily')
end
def self.ninety_days
new('hist-90d')
end
def self.historical
new('hist')
end
def initialize(scope)
@scope = scope
end
def each
document.locate('gesmes:Envelope/Cube/Cube').each do |day|
date = Date.parse(day['time'])
day.locate('Cube').each do |record|
yield(
date: date,
iso_code: record['currency'],
rate: Float(record['rate'])
)
end
end
end
private
def document
Ox.load(xml)
end
def xml
Net::HTTP.get(url)
end
def url
URI("http://www.ecb.europa.eu/stats/eurofxref/eurofxref-#{@scope}.xml")
end
end
end

11
lib/currency.rb Normal file
View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class Currency < Sequel::Model
dataset_module do
def latest(date = Date.today)
where(date: select(:date).where(Sequel.lit('date <= ?', date))
.order(Sequel.desc(:date))
.limit(1))
end
end
end

27
lib/query.rb Normal file
View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
class Query
def initialize(params = {})
@params = params
end
def amount
@params[:amount].to_f if @params[:amount] # rubocop:disable Style/SafeNavigation
end
def base
@params.values_at(:base, :from).compact.first&.upcase
end
def symbols
@params.values_at(:symbols, :to).compact.first&.split(',')
end
def date
@params[:date]
end
def to_h
{ amount: amount, base: base, date: date, symbols: symbols }.compact
end
end

61
lib/quotation.rb Normal file
View File

@ -0,0 +1,61 @@
# frozen_string_literal: true
require 'currency'
class Quotation
DEFAULT_BASE = 'EUR'
def initialize(amount: 1.0,
base: DEFAULT_BASE,
date: Date.today.to_s,
symbols: nil)
@amount = amount
@base = base
@date = date
@symbols = symbols
end
def quote
{ date: date, rates: calculate_rates }
end
def date
currencies.first[:date].to_s
end
private
def calculate_rates # rubocop:disable Metrics/AbcSize
rates = currencies.each_with_object({}) do |currency, hsh|
hsh[currency[:iso_code]] = currency[:rate]
end
return rates if @base == DEFAULT_BASE && @amount == 1.0
if @symbols.nil? || @symbols.include?(DEFAULT_BASE)
rates.update(DEFAULT_BASE => 1.0)
end
divisor = rates.delete(@base)
rates.sort.map! { |ic, rate| [ic, round(@amount * rate / divisor)] }.to_h
end
def currencies
@currencies ||= begin
scope = Currency.latest(@date)
scope = scope.where(iso_code: @symbols + [@base]) if @symbols
scope.order(:iso_code).naked
end
end
# To paraphrase Wikipedia, most currency pairs are quoted to four decimal
# places. An exception to this is exchange rates with a value of less than
# 1.000, which are quoted to five or six decimal places. Exchange rates
# greater than around 20 are usually quoted to three decimal places and
# exchange rates greater than 80 are quoted to two decimal places.
# Currencies over 5000 are usually quoted with no decimal places.
def round(rate)
Float(format('%.5g', rate))
end
end

View File

@ -1,3 +1,3 @@
limit_req_zone $binary_remote_addr zone=api:10m rate=1r/s; limit_req_zone $binary_remote_addr zone=api:10m rate=50r/s;
limit_req_status 429; limit_req_status 429;
limit_req zone=api burst=50 nodelay; limit_req zone=api burst=500 nodelay;

View File

@ -22,8 +22,10 @@ describe 'the API' do
end end
it 'sets base currency' do it 'sets base currency' do
get '/latest'
res = Oj.load(last_response.body)
get '/latest?base=USD' get '/latest?base=USD'
json['base'].must_equal 'USD' json.wont_equal res
end end
it 'sets base amount' do it 'sets base amount' do
@ -36,16 +38,6 @@ describe 'the API' do
json['rates'].keys.must_equal %w[USD] json['rates'].keys.must_equal %w[USD]
end end
it 'aliases base as from' do
get '/latest?from=USD'
json['base'].must_equal 'USD'
end
it 'aliases symbols as to' do
get '/latest?to=USD'
json['rates'].keys.must_equal %w[USD]
end
it 'returns historical quotes' do it 'returns historical quotes' do
get '/2012-11-20' get '/2012-11-20'
json['rates'].wont_be :empty? json['rates'].wont_be :empty?

View File

@ -1,47 +1,49 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative '../helper' require_relative '../helper'
require 'bank/feed'
module Fixer module Bank
describe Feed do describe Feed do
before { VCR.insert_cassette 'fixer' } before do
after { VCR.eject_cassette } VCR.insert_cassette 'feed'
end
after do
VCR.eject_cassette
end
it 'fetches current rates' do
feed = Feed.current
feed.count.must_be :<, 40
end
it 'fetches rates for the past 90 days' do
feed = Feed.ninety_days
feed.count.must_be :>, 33 * 60
end
it 'fetches historical rates' do
feed = Feed.historical
feed.count.must_be :>, 33 * 3000
end
it 'parses the date of a currency' do it 'parses the date of a currency' do
feed = Feed.new(:current) feed = Feed.current
currency = feed.first currency = feed.first
currency[:date].must_be_kind_of Date currency[:date].must_be_kind_of Date
end end
it 'parse the ISO code of a currency' do it 'parse the ISO code of a currency' do
feed = Feed.new(:current) feed = Feed.current
currency = feed.first currency = feed.first
currency[:iso_code].must_be_kind_of String currency[:iso_code].must_be_kind_of String
end end
it 'parses the rate of a currency' do it 'parses the rate of a currency' do
feed = Feed.new(:current) feed = Feed.current
currency = feed.first currency = feed.first
currency[:rate].must_be_kind_of Float currency[:rate].must_be_kind_of Float
end end
it 'fetches current rates' do
feed = Feed.new(:current)
feed.count.must_be :<, 40
end
it 'fetches rates for the past 90 days' do
feed = Feed.new(:ninety_days)
feed.count.must_be :>, 33 * 60
end
it 'fetches historical rates' do
feed = Feed.new(:historical)
feed.count.must_be :>, 33 * 3000
end
it 'raises error when scope is not valid' do
-> { Feed.new(:invalid) }.must_raise ArgumentError
end
end end
end end

View File

@ -12,9 +12,19 @@ describe Bank do
end end
before do before do
VCR.insert_cassette 'feed'
Currency.dataset.delete Currency.dataset.delete
end end
after do
VCR.eject_cassette
end
it 'fetches all rates' do
Bank.fetch_all_rates!
Currency.count.must_be :positive?
end
it 'fetches current rates' do it 'fetches current rates' do
Bank.fetch_current_rates! Bank.fetch_current_rates!
Currency.count.must_be :positive? Currency.count.must_be :positive?

40
spec/currency_spec.rb Normal file
View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
require_relative 'helper'
require 'currency'
describe Currency do
around do |test|
Currency.db.transaction do
test.call
raise Sequel::Rollback
end
end
before do
Currency.dataset.delete
@earlier = [
Currency.create(iso_code: 'EUR', rate: 1, date: '2014-01-01'),
Currency.create(iso_code: 'USD', rate: 2, date: '2014-01-01')
]
@later = [
Currency.create(iso_code: 'EUR', rate: 1, date: '2015-01-01'),
Currency.create(iso_code: 'USD', rate: 2, date: '2015-01-01')
]
end
it 'returns latest rates' do
data = Currency.latest.to_a
data.sample.date.must_equal @later.sample.date
end
it 'returns latest rates before given date' do
data = Currency.latest(@later.sample.date - 1).to_a
data.sample.date.must_equal @earlier.sample.date
end
it 'returns nothing if there are no rates before given date' do
data = Currency.latest(@earlier.sample.date - 1).to_a
data.must_be_empty
end
end

View File

@ -13,42 +13,31 @@ describe 'the API' do
it 'handles unfound pages' do it 'handles unfound pages' do
get '/foo' get '/foo'
last_response.status.must_equal 404 last_response.status.must_equal 404
json.wont_be_empty
end end
it 'will not process an invalid date' do it 'will not process an invalid date' do
get '/2010-31-01' get '/2010-31-01'
last_response.must_be :unprocessable? last_response.must_be :unprocessable?
json.wont_be_empty
end end
it 'will not process a date before 2000' do it 'will not process a date before 2000' do
get '/1999-01-01' get '/1999-01-01'
last_response.must_be :unprocessable? last_response.must_be :unprocessable?
json.wont_be_empty
end end
it 'will not process an invalid base' do it 'will not process an invalid base' do
get '/latest?base=UAH' get '/latest?base=UAH'
last_response.must_be :unprocessable? last_response.must_be :unprocessable?
json.wont_be_empty
end
it 'will not process an invalid amount' do
get '/latest?amount=foo'
last_response.must_be :unprocessable?
json.wont_be_empty
end end
it 'handles malformed queries' do it 'handles malformed queries' do
get '/latest?base=USD?callback=?' get '/latest?base=USD?callback=?'
last_response.must_be :unprocessable? last_response.must_be :unprocessable?
json.wont_be_empty
end end
it 'returns fresh dates' do it 'returns fresh dates' do
Currency.db.transaction do Currency.db.transaction do
new_date = Currency.current_date + 1 new_date = Currency.order(Sequel.desc(:date)).first.date + 1
Currency.create(date: new_date, iso_code: 'FOO', rate: 1) Currency.create(date: new_date, iso_code: 'FOO', rate: 1)
get '/latest' get '/latest'
json['date'].must_equal new_date.to_s json['date'].must_equal new_date.to_s

View File

@ -1,12 +1,16 @@
# frozen_string_literal: true # frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path('../../lib', __FILE__)) require_relative '../config/environment'
require 'minitest/autorun' require 'minitest/autorun'
require 'minitest/around/spec'
require 'vcr' require 'vcr'
require 'webmock' require 'webmock'
require 'fixer' begin
require 'pry'
rescue LoadError # rubocop:disable Lint/HandleExceptions
end
VCR.configure do |c| VCR.configure do |c|
c.cassette_library_dir = 'spec/vcr_cassettes' c.cassette_library_dir = 'spec/vcr_cassettes'

56
spec/query_spec.rb Normal file
View File

@ -0,0 +1,56 @@
# frozen_string_literal: true
require_relative 'helper'
require 'query'
describe Query do
it 'returns given amount' do
query = Query.new(amount: '100')
query.amount.must_equal 100.0
end
it 'defaults amount to nothin' do
query = Query.new
query.amount.must_be_nil
end
it 'returns given base' do
query = Query.new(base: 'USD')
query.base.must_equal 'USD'
end
it 'defaults base to nothing' do
query = Query.new
query.base.must_be_nil
end
it 'aliases base as from' do
query = Query.new(from: 'USD')
query.base.must_equal 'USD'
end
it 'returns given symbols' do
query = Query.new(symbols: 'USD,GBP')
query.symbols.must_equal %w[USD GBP]
end
it 'aliases symbols to to' do
query = Query.new(to: 'USD')
query.symbols.must_equal ['USD']
end
it 'defaults symbols to nothing' do
query = Query.new
query.symbols.must_be_nil
end
it 'returns given date' do
query = Query.new(date: '2014-01-01')
query.date.must_equal '2014-01-01'
end
it 'defaults date to nothing' do
query = Query.new
query.date.must_be_nil
end
end

45
spec/quotation_spec.rb Normal file
View File

@ -0,0 +1,45 @@
# frozen_string_literal: true
require_relative 'helper'
require 'quotation'
describe Quotation do
describe 'by default' do
it 'quotes against the Euro' do
quotation = Quotation.new
rates = quotation.quote[:rates]
rates.keys.wont_include 'EUR'
end
end
describe 'when given a base' do
it 'quotes against that base' do
quotation = Quotation.new(base: 'USD')
rates = quotation.quote[:rates]
rates.keys.wont_include 'USD'
end
it 'sorts rates' do
quotation = Quotation.new(base: 'USD')
rates = quotation.quote[:rates]
rates.keys.must_equal rates.keys.sort
end
end
describe 'when given symbols' do
it 'quotes rates only for given symbols' do
quotation = Quotation.new(symbols: ['USD'])
rates = quotation.quote[:rates]
rates.keys.must_include 'USD'
rates.keys.wont_include 'GBP'
end
end
describe 'when given an amount' do
it 'quotes for that amount' do
quotation = Quotation.new(amount: 100)
rates = quotation.quote[:rates]
rates['USD'].must_be :>, 10
end
end
end