Add exchange rate cache (#115)

* Add local cache for exchange rates for POA
pull/118/head
Alex Garibay 7 years ago committed by GitHub
parent 0e4f78e71b
commit e9824053f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      .gitignore
  2. 6
      apps/explorer/lib/explorer/application.ex
  3. 116
      apps/explorer/lib/explorer/exchange_rates/exchange_rates.ex
  4. 24
      apps/explorer/lib/explorer/exchange_rates/rate.ex
  5. 12
      apps/explorer/lib/explorer/exchange_rates/source.ex
  6. 60
      apps/explorer/lib/explorer/exchange_rates/source/coin_market_cap.ex
  7. 3
      apps/explorer/mix.exs
  8. 98
      apps/explorer/test/explorer/exchange_rates/exchange_rates_test.exs
  9. 79
      apps/explorer/test/explorer/exchange_rates/source/coin_market_cap_test.exs
  10. 2
      apps/explorer/test/test_helper.exs
  11. 2
      config/test.exs
  12. 3
      mix.lock

1
.gitignore vendored

@ -29,6 +29,7 @@ yarn-error.log
# secrets files as long as you replace their contents by environment
# variables.
/apps/*/config/*.secret.exs
/apps/*/cover/
# Wallaby screenshots
screenshots/

@ -23,7 +23,8 @@ defmodule Explorer.Application do
children() ++
[
supervisor(Exq, [exq_options]),
worker(Explorer.Servers.ChainStatistics, [])
worker(Explorer.Servers.ChainStatistics, []),
Explorer.ExchangeRates
]
end
@ -31,7 +32,8 @@ defmodule Explorer.Application do
import Supervisor.Spec
[
supervisor(Explorer.Repo, [])
supervisor(Explorer.Repo, []),
{Task.Supervisor, name: Explorer.ExchangeRateTaskSupervisor}
]
end
end

@ -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

@ -70,6 +70,7 @@ defmodule Explorer.Mixfile do
# Type `mix help deps` for examples and options.
defp deps do
[
{:bypass, "~> 0.8", only: :test},
{:credo, "~> 0.8", only: [:dev, :test], runtime: false},
{:crontab, "~> 1.1"},
{:dialyxir, "~> 0.5", only: [:dev, :test], runtime: false},
@ -82,9 +83,11 @@ defmodule Explorer.Mixfile do
{:exvcr, "~> 0.10", only: :test},
{:flow, "~> 0.12"},
{:httpoison, "~> 1.0", override: true},
{:jiffy, "~> 0.15.1"},
{:junit_formatter, ">= 0.0.0", only: [:test], runtime: false},
{:math, "~> 0.3.0"},
{:mock, "~> 0.3.0", only: [:test], runtime: false},
{:mox, "~> 0.3.2", only: [:test]},
{:new_relixir, "~> 0.4"},
{:postgrex, ">= 0.0.0"},
{:quantum, "~> 2.2.1"},

@ -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

@ -9,3 +9,5 @@ ExUnit.start()
{:ok, _} = Application.ensure_all_started(:ex_machina)
Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo, :manual)
Mox.defmock(Explorer.ExchangeRates.Source.TestSource, for: Explorer.ExchangeRates.Source)

@ -2,3 +2,5 @@ use Mix.Config
# Print only warnings and errors during test
config :logger, level: :warn
config :explorer, Explorer.ExchangeRates, source: Explorer.ExchangeRates.Source.TestSource

@ -1,6 +1,7 @@
%{
"abnf2": {:hex, :abnf2, "0.1.2", "6f8792b8ac3288dba5fc889c2bceae9fe78f74e1a7b36bea9726ffaa9d7bef95", [:mix], [], "hexpm"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"},
"bypass": {:hex, :bypass, "0.8.1", "16d409e05530ece4a72fabcf021a3e5c7e15dcc77f911423196a0c551f2a15ca", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"certifi": {:hex, :certifi, "2.3.1", "d0f424232390bf47d82da8478022301c561cf6445b5b5fb6a84d49a9e76d2639", [:rebar3], [{:parse_trans, "3.2.0", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"},
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
@ -31,6 +32,7 @@
"hackney": {:hex, :hackney, "1.12.1", "8bf2d0e11e722e533903fe126e14d6e7e94d9b7983ced595b75f532e04b7fdc7", [:rebar3], [{:certifi, "2.3.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
"httpoison": {:hex, :httpoison, "1.1.0", "497949fb62924432f64a45269d20e6f61ecf35084ffa270917afcdb7cd4d8061", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
"idna": {:hex, :idna, "5.1.1", "cbc3b2fa1645113267cc59c760bafa64b2ea0334635ef06dbac8801e42f7279c", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
"jiffy": {:hex, :jiffy, "0.15.1", "be83b09388da1a6c7e798207c9d6a1c4d71bb95fcc387d37d35861788f49ab97", [:rebar3], [], "hexpm"},
"jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"},
"junit_formatter": {:hex, :junit_formatter, "2.1.0", "ff03d2bbe9a67041f2488d8e72180ddcf4dff9e9c8a39b79eac9828fcb9e9bbf", [:mix], [], "hexpm"},
"math": {:hex, :math, "0.3.0", "e14e7291115201cb155a3567e66d196bf5088a6f55b030d598107d7ae934a11c", [:mix], [], "hexpm"},
@ -39,6 +41,7 @@
"mime": {:hex, :mime, "1.2.0", "78adaa84832b3680de06f88f0997e3ead3b451a440d183d688085be2d709b534", [:mix], [], "hexpm"},
"mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"},
"mock": {:hex, :mock, "0.3.1", "994f00150f79a0ea50dc9d86134cd9ebd0d177ad60bd04d1e46336cdfdb98ff9", [:mix], [{:meck, "~> 0.8.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"},
"mox": {:hex, :mox, "0.3.2", "3b9b8364fd4f28628139de701d97c636b27a8f925f57a8d5a1b85fbd620dad3a", [:mix], [], "hexpm"},
"new_relixir": {:hex, :new_relixir, "0.4.1", "55907ef2b968cf9c9278c58e43d1d68f756d21101fa552639c566c5536280915", [:mix], [{:ecto, "~> 2.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm"},
"parse_trans": {:hex, :parse_trans, "3.2.0", "2adfa4daf80c14dc36f522cf190eb5c4ee3e28008fc6394397c16f62a26258c2", [:rebar3], [], "hexpm"},
"phoenix": {:hex, :phoenix, "1.3.0", "1c01124caa1b4a7af46f2050ff11b267baa3edb441b45dbf243e979cd4c5891b", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},

Loading…
Cancel
Save