parent
0e4f78e71b
commit
e9824053f1
@ -0,0 +1,116 @@ |
||||
defmodule Explorer.ExchangeRates do |
||||
@moduledoc """ |
||||
Local cache for relevant exchange rates. |
||||
|
||||
Exchange rate data is updated every 5 minutes. |
||||
""" |
||||
|
||||
use GenServer |
||||
|
||||
require Logger |
||||
|
||||
alias Explorer.ExchangeRates.Rate |
||||
|
||||
@default_tickers ~w(poa-network) |
||||
@interval :timer.minutes(5) |
||||
@table_name :exchange_rates |
||||
|
||||
## GenServer functions |
||||
|
||||
@impl GenServer |
||||
def handle_info(:update, state) do |
||||
Logger.debug(fn -> "Updating cached exchange rates" end) |
||||
|
||||
for ticker <- @default_tickers do |
||||
fetch_ticker(ticker) |
||||
end |
||||
|
||||
{:noreply, state} |
||||
end |
||||
|
||||
# Callback for successful ticker fetch |
||||
@impl GenServer |
||||
def handle_info({_ref, {ticker, {:ok, %Rate{} = rate}}}, state) do |
||||
:ets.insert(table_name(), {ticker, rate}) |
||||
|
||||
{:noreply, state} |
||||
end |
||||
|
||||
# Callback for errored ticker fetch |
||||
@impl GenServer |
||||
def handle_info({_ref, {ticker, {:error, reason}}}, state) do |
||||
Logger.warn(fn -> |
||||
"Failed to get exchange rates for ticker '#{ticker}' with reason '#{reason}'." |
||||
end) |
||||
|
||||
fetch_ticker(ticker) |
||||
|
||||
{:noreply, state} |
||||
end |
||||
|
||||
# Callback that a monitored process has shutdown |
||||
@impl GenServer |
||||
def handle_info({:DOWN, _, :process, _, _}, state) do |
||||
{:noreply, state} |
||||
end |
||||
|
||||
@impl GenServer |
||||
def init(_) do |
||||
send(self(), :update) |
||||
:timer.send_interval(@interval, :update) |
||||
|
||||
table_opts = [ |
||||
:set, |
||||
:named_table, |
||||
:public, |
||||
read_concurrency: true, |
||||
write_concurrency: true |
||||
] |
||||
|
||||
:ets.new(table_name(), table_opts) |
||||
|
||||
{:ok, %{}} |
||||
end |
||||
|
||||
def start_link(opts) do |
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__) |
||||
end |
||||
|
||||
## Public functions |
||||
|
||||
@doc """ |
||||
Lists exchange rates for the tracked tickers. |
||||
""" |
||||
@spec all_tickers() :: [Rate.t()] |
||||
def all_tickers do |
||||
table_name() |
||||
|> :ets.tab2list() |
||||
|> Enum.map(fn {_, rate} -> rate end) |
||||
|> Enum.sort_by(fn %Rate{symbol: symbol} -> symbol end) |
||||
end |
||||
|
||||
## Undocumented public functions |
||||
|
||||
@doc false |
||||
@spec table_name() :: atom() |
||||
def table_name, do: @table_name |
||||
|
||||
## Private functions |
||||
|
||||
@spec config(atom()) :: term |
||||
defp config(key) do |
||||
Application.get_env(:explorer, __MODULE__, [])[key] |
||||
end |
||||
|
||||
@spec fetch_ticker(String.t()) :: Task.t() |
||||
defp fetch_ticker(ticker) do |
||||
Task.Supervisor.async_nolink(Explorer.ExchangeRateTaskSupervisor, fn -> |
||||
{ticker, ticker_source().fetch_exchange_rate(ticker)} |
||||
end) |
||||
end |
||||
|
||||
@spec ticker_source() :: module() |
||||
defp ticker_source do |
||||
config(:source) || Explorer.ExchangeRates.Source.CoinMarketCap |
||||
end |
||||
end |
@ -0,0 +1,24 @@ |
||||
defmodule Explorer.ExchangeRates.Rate do |
||||
@moduledoc """ |
||||
Data container for modeling an exchange rate. |
||||
""" |
||||
|
||||
@typedoc """ |
||||
Represents an exchange rate for a given currency. |
||||
|
||||
* `:id` - ID of a currency |
||||
* `:last_updated` - Timestamp of when the value was last updated |
||||
* `:name` - Human-readable name of a ticker |
||||
* `:symbol` - Trading symbol used to represent a currency |
||||
* `:usd_value` - The USD value of the currency |
||||
""" |
||||
@type t :: %__MODULE__{ |
||||
id: String.t(), |
||||
last_updated: DateTime.t(), |
||||
name: String.t(), |
||||
symbol: String.t(), |
||||
usd_value: String.t() |
||||
} |
||||
|
||||
defstruct ~w(id last_updated name symbol usd_value)a |
||||
end |
@ -0,0 +1,12 @@ |
||||
defmodule Explorer.ExchangeRates.Source do |
||||
@moduledoc """ |
||||
Behaviour for fetching exchange rates from external sources. |
||||
""" |
||||
|
||||
alias Explorer.ExchangeRates.Rate |
||||
|
||||
@doc """ |
||||
Callback for fetching an exchange rate for a given cryptocurrency. |
||||
""" |
||||
@callback fetch_exchange_rate(ticker :: String.t()) :: {:ok, Rate.t()} | {:error, any} |
||||
end |
@ -0,0 +1,60 @@ |
||||
defmodule Explorer.ExchangeRates.Source.CoinMarketCap do |
||||
@moduledoc """ |
||||
Adapter for fetching exchange rates from https://coinmarketcap.com. |
||||
""" |
||||
|
||||
alias Explorer.ExchangeRates.Rate |
||||
alias Explorer.ExchangeRates.Source |
||||
alias HTTPoison.Error |
||||
alias HTTPoison.Response |
||||
|
||||
@behaviour Source |
||||
|
||||
@impl Source |
||||
def fetch_exchange_rate(ticker) do |
||||
url = source_url(ticker) |
||||
headers = [{"Content-Type", "application/json"}] |
||||
|
||||
case HTTPoison.get(url, headers) do |
||||
{:ok, %Response{body: body, status_code: 200}} -> |
||||
{:ok, format_data(body)} |
||||
|
||||
{:ok, %Response{status_code: 404}} -> |
||||
{:error, :not_found} |
||||
|
||||
{:ok, %Response{body: body, status_code: status_code}} when status_code in 400..499 -> |
||||
{:error, decode_json(body)["error"]} |
||||
|
||||
{:error, %Error{reason: reason}} -> |
||||
{:error, reason} |
||||
end |
||||
end |
||||
|
||||
@doc false |
||||
def format_data(data) do |
||||
[json] = decode_json(data) |
||||
{last_updated_as_unix, _} = Integer.parse(json["last_updated"]) |
||||
last_updated = DateTime.from_unix!(last_updated_as_unix) |
||||
|
||||
%Rate{ |
||||
id: json["id"], |
||||
last_updated: last_updated, |
||||
name: json["name"], |
||||
symbol: json["symbol"], |
||||
usd_value: json["price_usd"] |
||||
} |
||||
end |
||||
|
||||
defp base_url do |
||||
configured_url = Application.get_env(:explorer, __MODULE__, [])[:base_url] |
||||
configured_url || "https://api.coinmarketcap.com" |
||||
end |
||||
|
||||
defp decode_json(data) do |
||||
:jiffy.decode(data, [:return_maps]) |
||||
end |
||||
|
||||
defp source_url(ticker) do |
||||
"#{base_url()}/v1/ticker/#{ticker}/" |
||||
end |
||||
end |
@ -0,0 +1,98 @@ |
||||
defmodule Explorer.ExchangeRatesTest do |
||||
use ExUnit.Case, async: false |
||||
|
||||
import Mox |
||||
|
||||
alias Explorer.ExchangeRates |
||||
alias Explorer.ExchangeRates.Rate |
||||
alias Explorer.ExchangeRates.Source.TestSource |
||||
|
||||
@moduletag :capture_log |
||||
|
||||
setup :verify_on_exit! |
||||
|
||||
test "start_link" do |
||||
stub(TestSource, :fetch_exchange_rate, fn _ -> {:ok, %Rate{}} end) |
||||
set_mox_global() |
||||
|
||||
assert {:ok, _} = ExchangeRates.start_link([]) |
||||
end |
||||
|
||||
test "init" do |
||||
assert :ets.info(ExchangeRates.table_name()) == :undefined |
||||
|
||||
assert {:ok, %{}} == ExchangeRates.init([]) |
||||
assert_received :update |
||||
table = :ets.info(ExchangeRates.table_name()) |
||||
refute table == :undefined |
||||
assert table[:name] == ExchangeRates.table_name() |
||||
assert table[:named_table] |
||||
assert table[:read_concurrency] |
||||
assert table[:type] == :set |
||||
assert table[:write_concurrency] |
||||
end |
||||
|
||||
test "handle_info with :update" do |
||||
ExchangeRates.init([]) |
||||
ticker = "poa-network" |
||||
state = %{} |
||||
|
||||
expect(TestSource, :fetch_exchange_rate, fn ^ticker -> {:ok, %Rate{}} end) |
||||
set_mox_global() |
||||
|
||||
assert {:noreply, ^state} = ExchangeRates.handle_info(:update, state) |
||||
assert_receive {_, {^ticker, _}} |
||||
end |
||||
|
||||
describe "ticker fetch task" do |
||||
setup do |
||||
ExchangeRates.init([]) |
||||
:ok |
||||
end |
||||
|
||||
test "with successful fetch" do |
||||
expected_rate = %Rate{ |
||||
id: "test", |
||||
last_updated: DateTime.utc_now(), |
||||
name: "test", |
||||
symbol: "test", |
||||
usd_value: "9000.000001" |
||||
} |
||||
|
||||
id = expected_rate.id |
||||
state = %{} |
||||
|
||||
assert {:noreply, ^state} = |
||||
ExchangeRates.handle_info({nil, {id, {:ok, expected_rate}}}, state) |
||||
|
||||
assert [{^id, ^expected_rate}] = :ets.lookup(ExchangeRates.table_name(), id) |
||||
end |
||||
|
||||
test "with failed fetch" do |
||||
ticker = "failed-ticker" |
||||
state = %{} |
||||
|
||||
expect(TestSource, :fetch_exchange_rate, fn "failed-ticker" -> {:ok, %Rate{}} end) |
||||
set_mox_global() |
||||
|
||||
assert {:noreply, ^state} = |
||||
ExchangeRates.handle_info({nil, {ticker, {:error, "some error"}}}, state) |
||||
|
||||
assert_receive {_, {^ticker, {:ok, _}}} |
||||
end |
||||
end |
||||
|
||||
test "all_tickers/0" do |
||||
ExchangeRates.init([]) |
||||
|
||||
rates = [ |
||||
%Rate{id: "z", symbol: "z"}, |
||||
%Rate{id: "a", symbol: "a"} |
||||
] |
||||
|
||||
expected_rates = Enum.reverse(rates) |
||||
for rate <- rates, do: :ets.insert(ExchangeRates.table_name(), {rate.id, rate}) |
||||
|
||||
assert expected_rates == ExchangeRates.all_tickers() |
||||
end |
||||
end |
@ -0,0 +1,79 @@ |
||||
defmodule Explorer.ExchangeRates.Source.CoinMarketCapTest do |
||||
use ExUnit.Case |
||||
|
||||
alias Explorer.ExchangeRates.Rate |
||||
alias Explorer.ExchangeRates.Source.CoinMarketCap |
||||
alias Plug.Conn |
||||
|
||||
@json """ |
||||
[ |
||||
{ |
||||
"id": "poa-network", |
||||
"name": "POA Network", |
||||
"symbol": "POA", |
||||
"rank": "103", |
||||
"price_usd": "0.485053", |
||||
"price_btc": "0.00007032", |
||||
"24h_volume_usd": "20185000.0", |
||||
"market_cap_usd": "98941986.0", |
||||
"available_supply": "203981804.0", |
||||
"total_supply": "254473964.0", |
||||
"max_supply": null, |
||||
"percent_change_1h": "-0.66", |
||||
"percent_change_24h": "12.34", |
||||
"percent_change_7d": "49.15", |
||||
"last_updated": "1523473200" |
||||
} |
||||
] |
||||
""" |
||||
|
||||
describe "fetch_exchange_rate" do |
||||
setup do |
||||
bypass = Bypass.open() |
||||
Application.put_env(:explorer, CoinMarketCap, base_url: "http://localhost:#{bypass.port}") |
||||
{:ok, bypass: bypass} |
||||
end |
||||
|
||||
test "with successful request", %{bypass: bypass} do |
||||
Bypass.expect(bypass, fn conn -> Conn.resp(conn, 200, @json) end) |
||||
|
||||
expected_date = ~N[2018-04-11 19:00:00] |> DateTime.from_naive!("Etc/UTC") |
||||
|
||||
expected = %Rate{ |
||||
id: "poa-network", |
||||
last_updated: expected_date, |
||||
name: "POA Network", |
||||
symbol: "POA", |
||||
usd_value: "0.485053" |
||||
} |
||||
|
||||
assert {:ok, ^expected} = CoinMarketCap.fetch_exchange_rate("poa-network") |
||||
end |
||||
|
||||
test "with invalid ticker", %{bypass: bypass} do |
||||
error_text = ~S({"error": "id not found"}) |
||||
Bypass.expect(bypass, fn conn -> Conn.resp(conn, 404, error_text) end) |
||||
assert {:error, :not_found} == CoinMarketCap.fetch_exchange_rate("poa-network") |
||||
end |
||||
|
||||
test "with bad request response", %{bypass: bypass} do |
||||
error_text = ~S({"error": "bad request"}) |
||||
Bypass.expect(bypass, fn conn -> Conn.resp(conn, 400, error_text) end) |
||||
assert {:error, "bad request"} == CoinMarketCap.fetch_exchange_rate("poa-network") |
||||
end |
||||
end |
||||
|
||||
test "format_data/1" do |
||||
expected_date = ~N[2018-04-11 19:00:00] |> DateTime.from_naive!("Etc/UTC") |
||||
|
||||
expected = %Rate{ |
||||
id: "poa-network", |
||||
last_updated: expected_date, |
||||
name: "POA Network", |
||||
symbol: "POA", |
||||
usd_value: "0.485053" |
||||
} |
||||
|
||||
assert expected == CoinMarketCap.format_data(@json) |
||||
end |
||||
end |
Loading…
Reference in new issue