mirror of
https://github.com/hakanensari/frankfurter.git
synced 2024-11-22 02:52:49 +01:00
Implement time series
... along with some minor miscellaneous refactoring This finally completes fixerAPI/fixer#22
This commit is contained in:
parent
fcc5f8504f
commit
a96e56808e
@ -3,6 +3,6 @@ AllCops:
|
||||
Documentation:
|
||||
Enabled: false
|
||||
Metrics/BlockLength:
|
||||
Enabled: false
|
||||
Metrics/LineLength:
|
||||
Enabled: false
|
||||
ExcludedMethods: ['describe', 'helpers']
|
||||
Metrics/MethodLength:
|
||||
Max: 11
|
||||
|
16
README.md
16
README.md
@ -8,10 +8,10 @@ Rates are updated around 4PM CET every working day.
|
||||
|
||||
## Usage
|
||||
|
||||
Get the latest foreign exchange rates.
|
||||
Get the current foreign exchange rates.
|
||||
|
||||
```http
|
||||
GET /latest
|
||||
GET /current
|
||||
```
|
||||
|
||||
Get historical rates for any day since 1999.
|
||||
@ -23,25 +23,25 @@ GET /2000-01-03
|
||||
Rates quote against the Euro by default. Quote against a different currency.
|
||||
|
||||
```http
|
||||
GET /latest?from=USD
|
||||
GET /current?from=USD
|
||||
```
|
||||
|
||||
Request specific exchange rates.
|
||||
|
||||
```http
|
||||
GET /latest?to=GBP
|
||||
GET /current?to=GBP
|
||||
```
|
||||
|
||||
Change the amount requested.
|
||||
Change the converted amount.
|
||||
|
||||
```http
|
||||
GET /latest?amount=100
|
||||
GET /current?amount=100
|
||||
```
|
||||
|
||||
Finally, use all the above together.
|
||||
|
||||
```http
|
||||
GET /latest?from=EUR&to=GBP&amount=100
|
||||
GET /current?from=EUR&to=GBP&amount=100
|
||||
```
|
||||
|
||||
The primary use case is client side. For instance, with [money.js](https://openexchangerates.github.io/money.js/) in the browser
|
||||
@ -52,7 +52,7 @@ let demo = () => {
|
||||
alert("£1 = $" + rate.toFixed(4))
|
||||
}
|
||||
|
||||
fetch('https://yourdomain.com/latest')
|
||||
fetch('https://yourdomain.com/current')
|
||||
.then((resp) => resp.json())
|
||||
.then((data) => fx.rates = data.rates)
|
||||
.then(demo)
|
||||
|
@ -4,4 +4,5 @@ require 'pg'
|
||||
require 'sequel'
|
||||
|
||||
Sequel.single_threaded = true
|
||||
Sequel.connect(ENV['DATABASE_URL'] || "postgres://localhost/frankfurter_#{App.env}")
|
||||
Sequel.connect(ENV['DATABASE_URL'] ||
|
||||
"postgres://localhost/frankfurter_#{App.env}")
|
||||
|
@ -7,5 +7,24 @@ class Currency < Sequel::Model
|
||||
.order(Sequel.desc(:date))
|
||||
.limit(1))
|
||||
end
|
||||
|
||||
def between(date_interval)
|
||||
query = where(date: date_interval).order(:date)
|
||||
length = date_interval.last - date_interval.first
|
||||
if length > 365
|
||||
query.sampled('month')
|
||||
elsif length > 90
|
||||
query.sampled('week')
|
||||
else
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
def sampled(precision)
|
||||
sampled_date = Sequel.lit("date_trunc('#{precision}', date)")
|
||||
select(:iso_code).select_append { avg(rate).as(rate) }
|
||||
.select_append(sampled_date.as(:date))
|
||||
.group(:iso_code, sampled_date)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
13
lib/query.rb
13
lib/query.rb
@ -6,19 +6,24 @@ class Query
|
||||
end
|
||||
|
||||
def amount
|
||||
@params[:amount].to_f if @params[:amount] # rubocop:disable Style/SafeNavigation
|
||||
return unless @params[:amount]
|
||||
@params[:amount].to_f
|
||||
end
|
||||
|
||||
def base
|
||||
@params.values_at(:base, :from).compact.first&.upcase
|
||||
@params.values_at(:from, :base).compact.first&.upcase
|
||||
end
|
||||
|
||||
def symbols
|
||||
@params.values_at(:symbols, :to).compact.first&.split(',')
|
||||
@params.values_at(:to, :symbols).compact.first&.split(',')
|
||||
end
|
||||
|
||||
def date
|
||||
@params[:date]
|
||||
if @params[:date]
|
||||
Date.parse(@params[:date])
|
||||
else
|
||||
(Date.parse(@params[:start_date])..Date.parse(@params[:end_date]))
|
||||
end
|
||||
end
|
||||
|
||||
def to_h
|
||||
|
61
lib/quote.rb
61
lib/quote.rb
@ -1,61 +1,4 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'currency'
|
||||
|
||||
class Quote
|
||||
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 to_h
|
||||
{ base: @base, 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) || @base == 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
|
||||
require 'quote/end_of_day'
|
||||
require 'quote/interval'
|
||||
|
82
lib/quote/base.rb
Normal file
82
lib/quote/base.rb
Normal file
@ -0,0 +1,82 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'roundable'
|
||||
|
||||
module Quote
|
||||
class Base
|
||||
include Roundable
|
||||
|
||||
DEFAULT_BASE = 'EUR'
|
||||
|
||||
attr_reader :amount, :base, :date, :symbols, :result
|
||||
|
||||
def initialize(date:, amount: 1.0, base: 'EUR', symbols: nil)
|
||||
@date = date
|
||||
@amount = amount
|
||||
@base = base
|
||||
@symbols = symbols
|
||||
@result = {}
|
||||
end
|
||||
|
||||
def perform
|
||||
return false if result.frozen?
|
||||
|
||||
prepare_rates
|
||||
rebase_rates if must_rebase?
|
||||
result.freeze
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def must_rebase?
|
||||
base != 'EUR'
|
||||
end
|
||||
|
||||
def formatted
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def not_found?
|
||||
result.empty?
|
||||
end
|
||||
|
||||
def cache_key
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def data
|
||||
@data ||= fetch_data
|
||||
end
|
||||
|
||||
def fetch_data
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def prepare_rates
|
||||
data.each_with_object(result) do |currency, result|
|
||||
date = currency[:date].to_date.to_s
|
||||
result[date] ||= {}
|
||||
result[date][currency[:iso_code]] = round(amount * currency[:rate])
|
||||
end
|
||||
end
|
||||
|
||||
def rebase_rates
|
||||
result.each do |date, rates|
|
||||
add_euro(rates)
|
||||
|
||||
divisor = rates.delete(base)
|
||||
result[date] = rates.sort
|
||||
.map! do |iso_code, rate|
|
||||
[iso_code, round(amount * rate / divisor)]
|
||||
end
|
||||
.to_h
|
||||
end
|
||||
end
|
||||
|
||||
def add_euro(rates)
|
||||
rates['EUR'] = amount if symbols.nil? || symbols.include?('EUR')
|
||||
end
|
||||
end
|
||||
end
|
26
lib/quote/end_of_day.rb
Normal file
26
lib/quote/end_of_day.rb
Normal file
@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'currency'
|
||||
require 'quote/base'
|
||||
|
||||
module Quote
|
||||
class EndOfDay < Base
|
||||
def formatted
|
||||
{ base: base, date: result.keys.first, rates: result.values.first }
|
||||
end
|
||||
|
||||
def cache_key
|
||||
return if not_found?
|
||||
Digest::MD5.hexdigest(result.keys.first)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_data
|
||||
scope = Currency.latest(date)
|
||||
scope = scope.where(iso_code: symbols + [base]) if symbols
|
||||
|
||||
scope.naked
|
||||
end
|
||||
end
|
||||
end
|
29
lib/quote/interval.rb
Normal file
29
lib/quote/interval.rb
Normal file
@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'currency'
|
||||
require 'quote/base'
|
||||
|
||||
module Quote
|
||||
class Interval < Base
|
||||
def formatted
|
||||
{ base: base,
|
||||
start_date: result.keys.first,
|
||||
end_date: result.keys.last,
|
||||
rates: result }
|
||||
end
|
||||
|
||||
def cache_key
|
||||
return if not_found?
|
||||
Digest::MD5.hexdigest(result.keys.last)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_data
|
||||
scope = Currency.between(date)
|
||||
scope = scope.where(iso_code: symbols + [base]) if symbols
|
||||
|
||||
scope.naked
|
||||
end
|
||||
end
|
||||
end
|
23
lib/roundable.rb
Normal file
23
lib/roundable.rb
Normal file
@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Roundable
|
||||
# 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(value)
|
||||
if value > 5000
|
||||
value.round
|
||||
elsif value > 80
|
||||
Float(format('%.2f', value))
|
||||
elsif value > 20
|
||||
Float(format('%.3f', value))
|
||||
elsif value > 1
|
||||
Float(format('%.4f', value))
|
||||
else
|
||||
Float(format('%.5f', value))
|
||||
end
|
||||
end
|
||||
end
|
BIN
lib/web/public/images/favicon.ico
Normal file
BIN
lib/web/public/images/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
Before Width: | Height: | Size: 10 KiB |
Binary file not shown.
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
@ -2,25 +2,44 @@ body {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
}
|
||||
|
||||
.navbar .navbar-nav .nav-link {
|
||||
strong {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: #337cf6;
|
||||
font-size: .875rem;
|
||||
padding-bottom: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.navbar .nav-link {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.navbar .nav-link:hover {
|
||||
color: #f8f9fa;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 720px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
.hero .container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero .logo {
|
||||
padding-bottom: 1em;
|
||||
width: 120px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.footer .container {
|
||||
@ -30,12 +49,10 @@ body {
|
||||
|
||||
.hljs {
|
||||
background-color: #f8f9fa;
|
||||
border: 2px solid #f8f9fa;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.hljs:hover {
|
||||
border-bottom-color: #dee2e6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@ -44,41 +61,49 @@ body {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
#carbonads {
|
||||
display: none;
|
||||
@media screen and (max-width: 767px) {
|
||||
.hero .logo {
|
||||
width: 75px;
|
||||
}
|
||||
|
||||
/*
|
||||
@media screen and (max-width: 767px) {
|
||||
#carbonads {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
#carbonads {
|
||||
background-color: #f0f0f0;
|
||||
background-color: #f8f9fa;
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
top: 36px;
|
||||
top: 24px;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.carbon-text {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.carbon-img,
|
||||
.carbon-poweredby,
|
||||
.carbon-text {
|
||||
display: block;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.carbon-text,
|
||||
.carbon-poweredby {
|
||||
line-height: 1.3;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.carbon-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.carbon-poweredby {
|
||||
color: #6c757d;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
|
@ -15,6 +15,10 @@ use Rack::Cors do
|
||||
end
|
||||
|
||||
configure :development do
|
||||
require 'rack-livereload'
|
||||
|
||||
use Rack::LiveReload
|
||||
|
||||
set :show_exceptions, :after_handler
|
||||
end
|
||||
|
||||
@ -27,13 +31,30 @@ configure :test do
|
||||
end
|
||||
|
||||
helpers do
|
||||
def quote
|
||||
@quote ||= begin
|
||||
query = Query.new(params)
|
||||
Quote.new(query.to_h)
|
||||
def end_of_day_quote
|
||||
@end_of_day_quote ||= begin
|
||||
quote = Quote::EndOfDay.new(query)
|
||||
quote.perform
|
||||
halt 404 if quote.not_found?
|
||||
|
||||
quote
|
||||
end
|
||||
end
|
||||
|
||||
def interval_quote
|
||||
@interval_quote ||= begin
|
||||
quote = Quote::Interval.new(query)
|
||||
quote.perform
|
||||
halt 404 if quote.not_found?
|
||||
|
||||
quote
|
||||
end
|
||||
end
|
||||
|
||||
def query
|
||||
Query.new(params).to_h
|
||||
end
|
||||
|
||||
def json(data)
|
||||
json = Oj.dump(data, mode: :compat)
|
||||
callback = params.delete('callback')
|
||||
@ -52,28 +73,25 @@ options '*' do
|
||||
200
|
||||
end
|
||||
|
||||
get '*' do
|
||||
cache_control :public
|
||||
pass
|
||||
end
|
||||
|
||||
get '/' do
|
||||
erb :index
|
||||
end
|
||||
|
||||
get '/(?:latest|current)', mustermann_opts: { type: :regexp } do
|
||||
last_modified quote.date
|
||||
json quote.to_h
|
||||
params[:date] = Date.today.to_s
|
||||
etag end_of_day_quote.cache_key
|
||||
json end_of_day_quote.formatted
|
||||
end
|
||||
|
||||
get '/(?<date>\d{4}-\d{2}-\d{2})', mustermann_opts: { type: :regexp } do
|
||||
last_modified quote.date
|
||||
json quote.to_h
|
||||
etag end_of_day_quote.cache_key
|
||||
json end_of_day_quote.formatted
|
||||
end
|
||||
|
||||
get '/(?<start_date>\d{4}-\d{2}-\d{2})\.\.(?<end_date>\d{4}-\d{2}-\d{2})', mustermann_opts: { type: :regexp } do
|
||||
last_modified quote.end_date
|
||||
json quote.to_h
|
||||
get '/(?<start_date>\d{4}-\d{2}-\d{2})\.\.(?<end_date>\d{4}-\d{2}-\d{2})',
|
||||
mustermann_opts: { type: :regexp } do
|
||||
etag interval_quote.cache_key
|
||||
json interval_quote.formatted
|
||||
end
|
||||
|
||||
not_found do
|
||||
|
@ -7,43 +7,43 @@
|
||||
<title>Frankfurter</title>
|
||||
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/styles/github.min.css">
|
||||
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.1.1/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Open+Sans:300,400">
|
||||
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Open+Sans:300,400,600">
|
||||
<link rel="stylesheet" href="/stylesheets/application.css">
|
||||
<link rel="shortcut icon" href="/images/frankfurter-icon.png">
|
||||
<link rel="shortcut icon" href="/images/favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand">
|
||||
<div class="container">
|
||||
<ul class="navbar-nav mx-sm-auto">
|
||||
<ul class="navbar-nav mx-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="https://github.com/hakanensari/frankfurter" target="_blank">Source code</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="https://www.ecb.europa.eu/stats/exchange/eurofxref/html/index.en.html" target="_blank">Data sets</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="https://status.frankfurter.app/" target="_blank">Status</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="https://github.com/hakanensari/frankfurter" target="_blank">Source</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
<section class="hero section">
|
||||
<div class="container">
|
||||
<image class="logo" src="/images/frankfurter.png" alt="">
|
||||
<h1 class="display-4">Frankfurter</h1>
|
||||
<p class="lead">
|
||||
Foreign exchange rates and currency conversion API
|
||||
</p>
|
||||
<img alt="" class="logo" src="/images/frankfurter.png">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="introduction section">
|
||||
<div class="container">
|
||||
<p>
|
||||
Frankfurter is a lightweight API for querying current and historical forex rates published by the European Central Bank.
|
||||
Frankfurter is an open-source API for current and historical forex rates published by the European Central Bank.
|
||||
</p>
|
||||
<p>
|
||||
Rates update around 4PM CET on every working day. Historical data goes back to early 1999.
|
||||
The API updates rates around 4PM CET every working day. Historical data goes back to early 1999.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@ -58,7 +58,11 @@
|
||||
<p>
|
||||
Get historical rates for any day since 1999.
|
||||
</p>
|
||||
<pre><code class="http hljs small">GET /1999-12-31 <i>HTTP/1.1</i></code></pre>
|
||||
<pre><code class="http hljs small">GET /2000-01-01 <i>HTTP/1.1</i></code></pre>
|
||||
<p>
|
||||
Get historical exchange rates for a given time period.
|
||||
</p>
|
||||
<pre><code class="http hljs small">GET /2000-01-01..2000-12-31 <i>HTTP/1.1</i></code></pre>
|
||||
<p>
|
||||
Rates are quoted against the Euro by default. Quote against a different
|
||||
currency by setting the <strong>from</strong> parameter in your request.
|
||||
@ -69,9 +73,9 @@
|
||||
</p>
|
||||
<pre><code class="http hljs small">GET /current?to=USD,GBP <i>HTTP/1.1</i></code></pre>
|
||||
<p>
|
||||
Convert a specific value.
|
||||
Convert a specific value using <strong>amount</strong>.
|
||||
</p>
|
||||
<pre><code class="http hljs small">GET /current?amount=1000&from=GBP&to=EUR <i>HTTP/1.1</i></code></pre>
|
||||
<pre><code class="http hljs small">GET /current?amount=1000 <i>HTTP/1.1</i></code></pre>
|
||||
</div>
|
||||
</section>
|
||||
<section class="best-practices section">
|
||||
@ -110,9 +114,9 @@ fetch('/current?from=GBP&to=USD')
|
||||
<iframe src="https://ghbtns.com/github-btn.html?user=hakanensari&repo=frankfurter&type=star&count=true" frameborder="0" scrolling="0" width="120px" height="20px"></iframe>
|
||||
</div>
|
||||
</section>
|
||||
<script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js"></script>
|
||||
<script src="/javascripts/application.js"></script>
|
||||
<script async type="text/javascript" src="//cdn.carbonads.com/carbon.js?zoneid=1673&serve=C6AILKT&placement=fixerio" id="_carbonads_js"></script>
|
||||
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js"></script>
|
||||
<script type="text/javascript" src="/javascripts/application.js"></script>
|
||||
<script async type="text/javascript" src="//cdn.carbonads.com/carbon.js?serve=CK7D45QM&placement=frankfurterapp" id="_carbonads_js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
@ -4,37 +4,52 @@ 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
|
||||
describe '.current' do
|
||||
it 'returns current rates' do
|
||||
date = Currency.order(Sequel.desc(:date)).first.date
|
||||
rates = Currency.latest
|
||||
rates.first.date.must_equal 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
|
||||
date = Date.parse('2010-01-01')
|
||||
rates = Currency.latest(date)
|
||||
rates.first.date.must_be :<=, 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
|
||||
rates = Currency.latest(Date.parse('1998-01-01'))
|
||||
rates.must_be_empty
|
||||
end
|
||||
end
|
||||
|
||||
describe '.between' do
|
||||
it 'returns rates between given dates' do
|
||||
start_date = Date.parse('2010-01-01')
|
||||
end_date = Date.parse('2010-01-31')
|
||||
dates = Currency.between((start_date..end_date)).map(:date).uniq.sort
|
||||
dates.first.must_be :>=, start_date
|
||||
dates.last.must_be :<=, end_date
|
||||
end
|
||||
|
||||
it 'returns nothing if there are no rates between given dates' do
|
||||
date_interval = (Date.parse('1998-01-01')..Date.parse('1998-01-31'))
|
||||
Currency.between(date_interval).must_be_empty
|
||||
end
|
||||
|
||||
it 'returns all rates up to 90 days' do
|
||||
date_interval = (Date.parse('2010-01-01')..Date.parse('2010-03-01'))
|
||||
Currency.between(date_interval).map(:date).uniq.count.must_be :>, 30
|
||||
end
|
||||
|
||||
it 'samples weeks over 90 days and below 366 days' do
|
||||
date_interval = (Date.parse('2010-01-01')..Date.parse('2010-12-31'))
|
||||
Currency.between(date_interval).map(:date).uniq.count.must_be :<=, 52
|
||||
end
|
||||
|
||||
it 'samples months over 365 days' do
|
||||
date_interval = (Date.parse('2001-01-01')..Date.parse('2010-12-31'))
|
||||
Currency.between(date_interval).map(:date).uniq.count.must_be :<=, 120
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -25,7 +25,7 @@ describe 'the API' do
|
||||
|
||||
it 'will not process a date before 2000' do
|
||||
get '/1999-01-01'
|
||||
last_response.must_be :unprocessable?
|
||||
last_response.must_be :not_found?
|
||||
end
|
||||
|
||||
it 'will not process an invalid base' do
|
||||
|
@ -1,5 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
ENV['APP_ENV'] ||= 'test'
|
||||
|
||||
require_relative '../config/environment'
|
||||
|
||||
require 'minitest/autorun'
|
||||
|
@ -9,7 +9,7 @@ describe Query do
|
||||
query.amount.must_equal 100.0
|
||||
end
|
||||
|
||||
it 'defaults amount to nothin' do
|
||||
it 'defaults amount to nothing' do
|
||||
query = Query.new
|
||||
query.amount.must_be_nil
|
||||
end
|
||||
@ -24,7 +24,7 @@ describe Query do
|
||||
query.base.must_be_nil
|
||||
end
|
||||
|
||||
it 'aliases base as from' do
|
||||
it 'aliases base with from' do
|
||||
query = Query.new(from: 'USD')
|
||||
query.base.must_equal 'USD'
|
||||
end
|
||||
@ -34,7 +34,7 @@ describe Query do
|
||||
query.symbols.must_equal %w[USD GBP]
|
||||
end
|
||||
|
||||
it 'aliases symbols to to' do
|
||||
it 'aliases symbols with to' do
|
||||
query = Query.new(to: 'USD')
|
||||
query.symbols.must_equal ['USD']
|
||||
end
|
||||
@ -45,12 +45,15 @@ describe Query do
|
||||
end
|
||||
|
||||
it 'returns given date' do
|
||||
query = Query.new(date: '2014-01-01')
|
||||
query.date.must_equal '2014-01-01'
|
||||
date = '2014-01-01'
|
||||
query = Query.new(date: date)
|
||||
query.date.must_equal Date.parse(date)
|
||||
end
|
||||
|
||||
it 'defaults date to nothing' do
|
||||
query = Query.new
|
||||
query.date.must_be_nil
|
||||
it 'returns given date interval' do
|
||||
start_date = '2014-01-01'
|
||||
end_date = '2014-12-31'
|
||||
query = Query.new(start_date: start_date, end_date: end_date)
|
||||
query.date.must_equal((Date.parse(start_date)..Date.parse(end_date)))
|
||||
end
|
||||
end
|
||||
|
55
spec/quote/base_spec.rb
Normal file
55
spec/quote/base_spec.rb
Normal file
@ -0,0 +1,55 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative '../helper'
|
||||
require 'quote/base'
|
||||
|
||||
module Quote
|
||||
describe Base do
|
||||
let(:klass) do
|
||||
Class.new(Base)
|
||||
end
|
||||
|
||||
let(:quote) do
|
||||
klass.new(date: Date.today)
|
||||
end
|
||||
|
||||
it 'requires data' do
|
||||
-> { quote.perform }.must_raise NotImplementedError
|
||||
end
|
||||
|
||||
it 'does not know how to format result' do
|
||||
-> { quote.formatted }.must_raise NotImplementedError
|
||||
end
|
||||
|
||||
it 'does not know how to generate a cache key' do
|
||||
-> { quote.cache_key }.must_raise NotImplementedError
|
||||
end
|
||||
|
||||
it 'defaults base to Euro' do
|
||||
quote.base.must_equal 'EUR'
|
||||
end
|
||||
|
||||
it 'defaults amount to 1' do
|
||||
quote.amount.must_equal 1
|
||||
end
|
||||
|
||||
describe 'when given data' do
|
||||
let(:klass) do
|
||||
Class.new(Base) do
|
||||
def fetch_data
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'performs' do
|
||||
assert quote.perform
|
||||
end
|
||||
|
||||
it 'performs only once' do
|
||||
quote.perform
|
||||
refute quote.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
83
spec/quote/end_of_day_spec.rb
Normal file
83
spec/quote/end_of_day_spec.rb
Normal file
@ -0,0 +1,83 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative '../helper'
|
||||
require 'quote/end_of_day'
|
||||
|
||||
module Quote
|
||||
describe EndOfDay do
|
||||
let(:date) do
|
||||
Date.parse('2010-10-10')
|
||||
end
|
||||
|
||||
let(:quote) do
|
||||
EndOfDay.new(date: date)
|
||||
end
|
||||
|
||||
before do
|
||||
quote.perform
|
||||
end
|
||||
|
||||
it 'returns rates' do
|
||||
quote.formatted[:rates].wont_be :empty?
|
||||
end
|
||||
|
||||
it 'quotes given date' do
|
||||
Date.parse(quote.formatted[:date]).must_be :<=, date
|
||||
end
|
||||
|
||||
it 'quotes against the Euro' do
|
||||
quote.formatted[:rates].keys.wont_include 'EUR'
|
||||
end
|
||||
|
||||
it 'sorts rates' do
|
||||
rates = quote.formatted[:rates]
|
||||
rates.keys.must_equal rates.keys.sort
|
||||
end
|
||||
|
||||
it 'has a cache key' do
|
||||
quote.cache_key.wont_be :empty?
|
||||
end
|
||||
|
||||
describe 'given a new base' do
|
||||
let(:quote) do
|
||||
EndOfDay.new(date: date, base: 'USD')
|
||||
end
|
||||
|
||||
it 'quotes against that base' do
|
||||
quote.formatted[:rates].keys.wont_include 'USD'
|
||||
end
|
||||
|
||||
it 'sorts rates' do
|
||||
rates = quote.formatted[:rates]
|
||||
rates.keys.must_equal rates.keys.sort
|
||||
end
|
||||
end
|
||||
|
||||
describe 'given symbols' do
|
||||
let(:quote) do
|
||||
EndOfDay.new(date: date, symbols: %w[USD GBP JPY])
|
||||
end
|
||||
|
||||
it 'quotes only for those symbols' do
|
||||
rates = quote.formatted[:rates]
|
||||
rates.keys.must_include 'USD'
|
||||
rates.keys.wont_include 'CAD'
|
||||
end
|
||||
|
||||
it 'sorts rates' do
|
||||
rates = quote.formatted[:rates]
|
||||
rates.keys.must_equal rates.keys.sort
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when given an amount' do
|
||||
let(:quote) do
|
||||
EndOfDay.new(date: date, amount: 100)
|
||||
end
|
||||
|
||||
it 'calculates quotes for that amount' do
|
||||
quote.formatted[:rates]['USD'].must_be :>, 10
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
94
spec/quote/interval_spec.rb
Normal file
94
spec/quote/interval_spec.rb
Normal file
@ -0,0 +1,94 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative '../helper'
|
||||
require 'quote/interval'
|
||||
|
||||
module Quote
|
||||
describe Interval do
|
||||
let(:date_interval) do
|
||||
(Date.parse('2010-01-01')..Date.parse('2010-12-31'))
|
||||
end
|
||||
|
||||
let(:quote) do
|
||||
Interval.new(date: date_interval)
|
||||
end
|
||||
|
||||
before do
|
||||
quote.perform
|
||||
end
|
||||
|
||||
it 'returns rates' do
|
||||
quote.formatted[:rates].wont_be :empty?
|
||||
end
|
||||
|
||||
it 'quotes given date interval' do
|
||||
Date.parse(quote.formatted[:start_date]).must_be :>=, date_interval.first
|
||||
Date.parse(quote.formatted[:end_date]).must_be :<=, date_interval.last
|
||||
end
|
||||
|
||||
it 'quotes against the Euro' do
|
||||
quote.formatted[:rates].each_value do |rates|
|
||||
rates.keys.wont_include 'EUR'
|
||||
end
|
||||
end
|
||||
|
||||
it 'sorts rates' do
|
||||
quote.formatted[:rates].each_value do |rates|
|
||||
rates.keys.must_equal rates.keys.sort
|
||||
end
|
||||
end
|
||||
|
||||
it 'has a cache key' do
|
||||
quote.cache_key.wont_be :empty?
|
||||
end
|
||||
|
||||
describe 'given a new base' do
|
||||
let(:quote) do
|
||||
Interval.new(date: date_interval, base: 'USD')
|
||||
end
|
||||
|
||||
it 'quotes against that base' do
|
||||
quote.formatted[:rates].each_value do |rates|
|
||||
rates.keys.wont_include 'USD'
|
||||
end
|
||||
end
|
||||
|
||||
it 'sorts rates' do
|
||||
quote.formatted[:rates].each_value do |rates|
|
||||
rates.keys.must_equal rates.keys.sort
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'given symbols' do
|
||||
let(:quote) do
|
||||
Interval.new(date: date_interval, symbols: %w[USD GBP JPY])
|
||||
end
|
||||
|
||||
it 'quotes only for those symbols' do
|
||||
quote.formatted[:rates].each_value do |rates|
|
||||
rates.keys.must_include 'USD'
|
||||
rates.keys.wont_include 'CAD'
|
||||
end
|
||||
end
|
||||
|
||||
it 'sorts rates' do
|
||||
quote.formatted[:rates].each_value do |rates|
|
||||
rates.keys.must_equal rates.keys.sort
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when given an amount' do
|
||||
let(:quote) do
|
||||
Interval.new(date: date_interval, amount: 100)
|
||||
end
|
||||
|
||||
it 'calculates quotes for that amount' do
|
||||
quote.formatted[:rates].each_value do |rates|
|
||||
rates['USD'].must_be :>, 10
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -1,53 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'helper'
|
||||
require 'quote'
|
||||
|
||||
describe Quote do
|
||||
describe 'by default' do
|
||||
it 'quotes against the Euro' do
|
||||
quote = Quote.new
|
||||
rates = quote.to_h[:rates]
|
||||
rates.keys.wont_include 'EUR'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when given a base' do
|
||||
it 'quotes against that base' do
|
||||
quote = Quote.new(base: 'USD')
|
||||
rates = quote.to_h[:rates]
|
||||
rates.keys.wont_include 'USD'
|
||||
end
|
||||
|
||||
it 'sorts rates' do
|
||||
quote = Quote.new(base: 'USD')
|
||||
rates = quote.to_h[:rates]
|
||||
rates.keys.must_equal rates.keys.sort
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when given symbols' do
|
||||
it 'quotes rates only for given symbols' do
|
||||
quote = Quote.new(symbols: ['USD'])
|
||||
rates = quote.to_h[: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
|
||||
quote = Quote.new(amount: 100)
|
||||
rates = quote.to_h[:rates]
|
||||
rates['USD'].must_be :>, 10
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when given an amount and symbols' do
|
||||
it 'quotes for that amount' do
|
||||
quote = Quote.new(amount: 100, symbols: ['USD'])
|
||||
rates = quote.to_h[:rates]
|
||||
rates['USD'].must_be :>, 10
|
||||
end
|
||||
end
|
||||
end
|
31
spec/roundable_spec.rb
Normal file
31
spec/roundable_spec.rb
Normal file
@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'helper'
|
||||
require 'roundable'
|
||||
|
||||
describe Roundable do
|
||||
include Roundable
|
||||
|
||||
it 'rounds values over 5,000 to zero decimal places' do
|
||||
round(5000.123456).must_equal 5000.0
|
||||
end
|
||||
|
||||
it 'rounds values over 80 and below 5,000 to two decimal places' do
|
||||
round(80.123456).must_equal 80.12
|
||||
round(4999.123456).must_equal 4999.12
|
||||
end
|
||||
|
||||
it 'rounds values over 20 and below 80 to three decimal places' do
|
||||
round(79.123456).must_equal 79.123
|
||||
round(20.123456).must_equal 20.123
|
||||
end
|
||||
|
||||
it 'rounds values over 1 and below 20 to four decimal places' do
|
||||
round(19.123456).must_equal 19.1235
|
||||
round(1.123456).must_equal 1.1235
|
||||
end
|
||||
|
||||
it 'rounds values below 1 to five decimal places' do
|
||||
round(0.123456).must_equal 0.12346
|
||||
end
|
||||
end
|
@ -49,17 +49,10 @@ describe 'the server' do
|
||||
json['rates'].wont_be :empty?
|
||||
end
|
||||
|
||||
it 'returns a cache control header' do
|
||||
%w[/ /current /2012-11-20].each do |path|
|
||||
get path
|
||||
headers['Cache-Control'].wont_be_nil
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns a last modified header' do
|
||||
it 'returns an ETag' do
|
||||
%w[/current /2012-11-20].each do |path|
|
||||
get path
|
||||
headers['Last-Modified'].wont_be_nil
|
||||
headers['ETag'].wont_be_nil
|
||||
end
|
||||
end
|
||||
|
||||
@ -88,8 +81,8 @@ describe 'the server' do
|
||||
|
||||
it 'returns rates for a given period' do
|
||||
get '/2010-01-01..2010-12-31'
|
||||
json['start_date'].must_equal '2010-01-01'
|
||||
json['end_date'].must_equal '2010-12-31'
|
||||
json['rates'].wont_be empty
|
||||
json['start_date'].wont_be :empty?
|
||||
json['end_date'].wont_be :empty?
|
||||
json['rates'].wont_be :empty?
|
||||
end
|
||||
end
|
||||
|
Loading…
Reference in New Issue
Block a user