mirror of
https://github.com/hakanensari/frankfurter.git
synced 2024-11-21 18:42:29 +01:00
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:
parent
9d0d22e504
commit
cfbb4ac4ac
@ -1,5 +1,5 @@
|
||||
APP_ENV=production
|
||||
HTTPS_METHOD=noredirect
|
||||
LETSENCRYPT_EMAIL=foo@bar.com
|
||||
LETSENCRYPT_HOST=example.com,www.example.com
|
||||
RACK_ENV=production
|
||||
VIRTUAL_HOST=example.com,www.example.com
|
||||
|
17
.travis.yml
17
.travis.yml
@ -1,10 +1,11 @@
|
||||
before_install:
|
||||
- gem update --system
|
||||
- gem install bundler
|
||||
- psql -c 'create database fixer_test;' -U postgres
|
||||
- cd app
|
||||
env:
|
||||
- RACK_ENV=test
|
||||
language: ruby
|
||||
sudo: false
|
||||
|
||||
rvm:
|
||||
- 2.5.0
|
||||
sudo: false
|
||||
env:
|
||||
- APP_ENV=test
|
||||
|
||||
bundler_args: "--without development"
|
||||
before_install:
|
||||
- "psql -c 'create database fixer_test;' -U postgres"
|
||||
|
@ -4,8 +4,8 @@ source 'http://rubygems.org'
|
||||
|
||||
ruby '2.5.0'
|
||||
|
||||
gem 'fixer'
|
||||
gem 'oj'
|
||||
gem 'ox'
|
||||
gem 'rack-cors'
|
||||
gem 'rake'
|
||||
gem 'rufus-scheduler'
|
||||
@ -14,8 +14,14 @@ gem 'sinatra'
|
||||
gem 'unicorn'
|
||||
|
||||
group :development do
|
||||
gem 'pry'
|
||||
end
|
||||
|
||||
group :test do
|
||||
gem 'minitest'
|
||||
gem 'minitest-around'
|
||||
gem 'rack-test'
|
||||
gem 'rubocop'
|
||||
gem 'vcr'
|
||||
gem 'webmock'
|
||||
end
|
@ -1,33 +1,44 @@
|
||||
GEM
|
||||
remote: http://rubygems.org/
|
||||
specs:
|
||||
ast (2.3.0)
|
||||
et-orbi (1.0.8)
|
||||
addressable (2.5.2)
|
||||
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
|
||||
fixer (1.0.0)
|
||||
kgio (2.11.1)
|
||||
minitest (5.11.1)
|
||||
minitest-around (0.4.0)
|
||||
hashdiff (0.3.7)
|
||||
kgio (2.11.2)
|
||||
method_source (0.9.0)
|
||||
minitest (5.11.3)
|
||||
minitest-around (0.4.1)
|
||||
minitest (~> 5.0)
|
||||
mustermann (1.0.1)
|
||||
oj (3.3.10)
|
||||
mustermann (1.0.2)
|
||||
oj (3.5.0)
|
||||
ox (2.8.4)
|
||||
parallel (1.12.1)
|
||||
parser (2.4.0.2)
|
||||
ast (~> 2.3)
|
||||
pg (0.21.0)
|
||||
parser (2.5.0.3)
|
||||
ast (~> 2.4.0)
|
||||
pg (1.0.0)
|
||||
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-protection (2.0.0)
|
||||
rack-protection (2.0.1)
|
||||
rack
|
||||
rack-test (0.8.2)
|
||||
rack-test (0.8.3)
|
||||
rack (>= 1.0, < 3)
|
||||
rainbow (3.0.0)
|
||||
raindrops (0.19.0)
|
||||
rake (12.3.0)
|
||||
rubocop (0.52.1)
|
||||
rubocop (0.53.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 2.4.0.2, < 3.0)
|
||||
parser (>= 2.5)
|
||||
powerpack (~> 0.1)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
@ -35,32 +46,39 @@ GEM
|
||||
ruby-progressbar (1.9.0)
|
||||
rufus-scheduler (3.4.2)
|
||||
et-orbi (~> 1.0)
|
||||
sequel (5.4.0)
|
||||
safe_yaml (1.0.4)
|
||||
sequel (5.6.0)
|
||||
sequel_pg (1.8.1)
|
||||
pg (>= 0.18.0)
|
||||
sequel (>= 4.34.0)
|
||||
sinatra (2.0.0)
|
||||
sinatra (2.0.1)
|
||||
mustermann (~> 1.0)
|
||||
rack (~> 2.0)
|
||||
rack-protection (= 2.0.0)
|
||||
rack-protection (= 2.0.1)
|
||||
tilt (~> 2.0)
|
||||
thread_safe (0.3.6)
|
||||
tilt (2.0.8)
|
||||
tzinfo (1.2.4)
|
||||
tzinfo (1.2.5)
|
||||
thread_safe (~> 0.1)
|
||||
unicode-display_width (1.3.0)
|
||||
unicorn (5.4.0)
|
||||
kgio (~> 2.6)
|
||||
raindrops (~> 0.7)
|
||||
vcr (4.0.0)
|
||||
webmock (3.3.0)
|
||||
addressable (>= 2.3.6)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
fixer
|
||||
minitest
|
||||
minitest-around
|
||||
oj
|
||||
ox
|
||||
pry
|
||||
rack-cors
|
||||
rack-test
|
||||
rake
|
||||
@ -69,6 +87,8 @@ DEPENDENCIES
|
||||
sequel_pg
|
||||
sinatra
|
||||
unicorn
|
||||
vcr
|
||||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 2.5.0p0
|
22
README.md
22
README.md
@ -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).
|
||||
|
||||
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.
|
||||
|
||||
## Usage
|
||||
@ -22,16 +20,22 @@ Get historical rates for any day since 1999.
|
||||
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
|
||||
GET /latest?base=USD
|
||||
GET /latest?from=USD
|
||||
```
|
||||
|
||||
Request specific exchange rates by setting the symbols parameter.
|
||||
Request specific exchange rates.
|
||||
|
||||
```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
|
||||
@ -42,7 +46,7 @@ let demo = () => {
|
||||
alert("£1 = $" + rate.toFixed(4))
|
||||
}
|
||||
|
||||
fetch('https://api.fixer.io/latest')
|
||||
fetch('https://api.example.com/latest')
|
||||
.then((resp) => resp.json())
|
||||
.then((data) => fx.rates = data.rates)
|
||||
.then(demo)
|
||||
@ -50,8 +54,6 @@ fetch('https://api.fixer.io/latest')
|
||||
|
||||
## Installation
|
||||
|
||||
I have included a sample Docker Compose configuration in the repo.
|
||||
|
||||
To build locally, type
|
||||
|
||||
```bash
|
||||
@ -64,7 +66,7 @@ Now you can access the API at
|
||||
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
|
||||
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
|
@ -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
|
@ -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
|
@ -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
|
@ -1,5 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require './config/environment'
|
||||
require 'minitest/autorun'
|
||||
require 'minitest/around/spec'
|
@ -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
|
@ -6,7 +6,7 @@ require 'pathname'
|
||||
module App
|
||||
class << self
|
||||
def env
|
||||
ENV['RACK_ENV'] || 'development'
|
||||
ENV['APP_ENV'] || 'development'
|
||||
end
|
||||
|
||||
def root
|
@ -1,15 +1,13 @@
|
||||
version: '3'
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: ./app
|
||||
build: .
|
||||
environment:
|
||||
RACK_ENV: development
|
||||
APP_ENV: development
|
||||
VIRTUAL_HOST: localhost
|
||||
ports:
|
||||
- "8080:8080"
|
||||
scheduler:
|
||||
build:
|
||||
context: ./app
|
||||
build: .
|
||||
environment:
|
||||
RACK_ENV: development
|
||||
APP_ENV: development
|
||||
|
@ -1,5 +0,0 @@
|
||||
AllCops:
|
||||
TargetRubyVersion: 2.5
|
||||
Metrics/BlockLength:
|
||||
Exclude:
|
||||
- 'spec/**/*'
|
@ -1,4 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
source 'https://rubygems.org'
|
||||
gemspec
|
@ -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
|
@ -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
|
||||
```
|
14
gem/Rakefile
14
gem/Rakefile
@ -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]
|
@ -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
|
@ -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
|
@ -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
|
@ -1,5 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Fixer
|
||||
VERSION = '1.0.0'
|
||||
end
|
@ -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
|
@ -3,7 +3,9 @@
|
||||
require 'oj'
|
||||
require 'sinatra'
|
||||
require 'rack/cors'
|
||||
require 'quote'
|
||||
|
||||
require 'query'
|
||||
require 'quotation'
|
||||
|
||||
use Rack::Cors do
|
||||
allow do
|
||||
@ -20,13 +22,20 @@ configure :production do
|
||||
disable :dump_errors
|
||||
end
|
||||
|
||||
configure :test do
|
||||
set :raise_errors, false
|
||||
end
|
||||
|
||||
helpers do
|
||||
def quote
|
||||
@quote ||= Quote.new(params)
|
||||
def quotation
|
||||
@quotation ||= begin
|
||||
query = Query.new(params)
|
||||
Quotation.new(query.to_h)
|
||||
end
|
||||
end
|
||||
|
||||
def jsonp(data)
|
||||
json = encode_json(data)
|
||||
json = Oj.dump(data, mode: :compat)
|
||||
callback = params.delete('callback')
|
||||
if callback
|
||||
content_type :js
|
||||
@ -36,10 +45,6 @@ helpers do
|
||||
json
|
||||
end
|
||||
end
|
||||
|
||||
def encode_json(data)
|
||||
Oj.dump(data, mode: :compat)
|
||||
end
|
||||
end
|
||||
|
||||
options '*' do
|
||||
@ -52,23 +57,23 @@ get '*' do
|
||||
end
|
||||
|
||||
get '/' do
|
||||
jsonp details: 'http://fixer.io'
|
||||
jsonp source: 'https://github.com/hakanensari/fixer'
|
||||
end
|
||||
|
||||
get '/latest' do
|
||||
last_modified quote.date
|
||||
jsonp quote.to_h
|
||||
last_modified quotation.date
|
||||
jsonp quotation.quote
|
||||
end
|
||||
|
||||
get '/(?<date>\d{4}-\d{2}-\d{2})', mustermann_opts: { type: :regexp } do
|
||||
last_modified quote.date
|
||||
jsonp quote.to_h
|
||||
last_modified quotation.date
|
||||
jsonp quotation.quote
|
||||
end
|
||||
|
||||
not_found do
|
||||
halt 404, encode_json(error: 'Not found')
|
||||
halt 404
|
||||
end
|
||||
|
||||
error Quote::Invalid do |ex|
|
||||
halt 422, encode_json(error: ex.message)
|
||||
error do
|
||||
halt 422
|
||||
end
|
@ -1,20 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'currency'
|
||||
require 'fixer'
|
||||
require 'bank/feed'
|
||||
|
||||
module Bank
|
||||
def self.fetch_all_rates!
|
||||
Currency.db.transaction do
|
||||
Currency.dataset.delete
|
||||
data = Fixer.historical
|
||||
Currency.multi_insert(data.to_a)
|
||||
Currency.multi_insert(Feed.historical.to_a)
|
||||
end
|
||||
end
|
||||
|
||||
def self.fetch_current_rates!
|
||||
Currency.db.transaction do
|
||||
Fixer.current.each do |hsh|
|
||||
Feed.current.each do |hsh|
|
||||
Currency.find_or_create(hsh)
|
||||
end
|
||||
end
|
53
lib/bank/feed.rb
Normal file
53
lib/bank/feed.rb
Normal 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
11
lib/currency.rb
Normal 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
27
lib/query.rb
Normal 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
61
lib/quotation.rb
Normal 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
|
@ -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 zone=api burst=50 nodelay;
|
||||
limit_req zone=api burst=500 nodelay;
|
||||
|
@ -22,8 +22,10 @@ describe 'the API' do
|
||||
end
|
||||
|
||||
it 'sets base currency' do
|
||||
get '/latest'
|
||||
res = Oj.load(last_response.body)
|
||||
get '/latest?base=USD'
|
||||
json['base'].must_equal 'USD'
|
||||
json.wont_equal res
|
||||
end
|
||||
|
||||
it 'sets base amount' do
|
||||
@ -36,16 +38,6 @@ describe 'the API' do
|
||||
json['rates'].keys.must_equal %w[USD]
|
||||
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
|
||||
get '/2012-11-20'
|
||||
json['rates'].wont_be :empty?
|
@ -1,47 +1,49 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative '../helper'
|
||||
require 'bank/feed'
|
||||
|
||||
module Fixer
|
||||
module Bank
|
||||
describe Feed do
|
||||
before { VCR.insert_cassette 'fixer' }
|
||||
after { VCR.eject_cassette }
|
||||
before do
|
||||
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
|
||||
feed = Feed.new(:current)
|
||||
feed = Feed.current
|
||||
currency = feed.first
|
||||
currency[:date].must_be_kind_of Date
|
||||
end
|
||||
|
||||
it 'parse the ISO code of a currency' do
|
||||
feed = Feed.new(:current)
|
||||
feed = Feed.current
|
||||
currency = feed.first
|
||||
currency[:iso_code].must_be_kind_of String
|
||||
end
|
||||
|
||||
it 'parses the rate of a currency' do
|
||||
feed = Feed.new(:current)
|
||||
feed = Feed.current
|
||||
currency = feed.first
|
||||
currency[:rate].must_be_kind_of Float
|
||||
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
|
@ -12,9 +12,19 @@ describe Bank do
|
||||
end
|
||||
|
||||
before do
|
||||
VCR.insert_cassette 'feed'
|
||||
Currency.dataset.delete
|
||||
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
|
||||
Bank.fetch_current_rates!
|
||||
Currency.count.must_be :positive?
|
40
spec/currency_spec.rb
Normal file
40
spec/currency_spec.rb
Normal 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
|
@ -13,42 +13,31 @@ describe 'the API' do
|
||||
it 'handles unfound pages' do
|
||||
get '/foo'
|
||||
last_response.status.must_equal 404
|
||||
json.wont_be_empty
|
||||
end
|
||||
|
||||
it 'will not process an invalid date' do
|
||||
get '/2010-31-01'
|
||||
last_response.must_be :unprocessable?
|
||||
json.wont_be_empty
|
||||
end
|
||||
|
||||
it 'will not process a date before 2000' do
|
||||
get '/1999-01-01'
|
||||
last_response.must_be :unprocessable?
|
||||
json.wont_be_empty
|
||||
end
|
||||
|
||||
it 'will not process an invalid base' do
|
||||
get '/latest?base=UAH'
|
||||
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
|
||||
|
||||
it 'handles malformed queries' do
|
||||
get '/latest?base=USD?callback=?'
|
||||
last_response.must_be :unprocessable?
|
||||
json.wont_be_empty
|
||||
end
|
||||
|
||||
it 'returns fresh dates' 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)
|
||||
get '/latest'
|
||||
json['date'].must_equal new_date.to_s
|
@ -1,12 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
$LOAD_PATH.unshift(File.expand_path('../../lib', __FILE__))
|
||||
require_relative '../config/environment'
|
||||
|
||||
require 'minitest/autorun'
|
||||
require 'minitest/around/spec'
|
||||
require 'vcr'
|
||||
require 'webmock'
|
||||
|
||||
require 'fixer'
|
||||
begin
|
||||
require 'pry'
|
||||
rescue LoadError # rubocop:disable Lint/HandleExceptions
|
||||
end
|
||||
|
||||
VCR.configure do |c|
|
||||
c.cassette_library_dir = 'spec/vcr_cassettes'
|
56
spec/query_spec.rb
Normal file
56
spec/query_spec.rb
Normal 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
45
spec/quotation_spec.rb
Normal 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
|
Loading…
Reference in New Issue
Block a user