Packages
ex_money
3.2.3
6.1.1
6.1.0
6.0.0
6.0.0-rc.0
5.24.2
5.24.1
5.24.0
retired
5.23.0
5.22.0
5.21.0
5.20.0
5.19.2
5.19.1
5.19.0
5.18.0
5.17.2
5.17.1
5.17.0
5.16.1
5.16.0
5.15.4
5.15.3
5.15.2
5.15.1
5.15.0
5.14.1
5.13.0
5.12.4
5.12.3
5.12.2
5.12.1
5.12.0
5.11.0
5.10.0
5.9.0
5.8.0
5.7.4
5.7.3
5.7.2
5.7.1
5.7.0
5.6.0
5.5.5
5.5.4
5.5.3
5.5.2
5.5.1
5.5.0
5.4.1
5.4.0
5.4.0-rc.0
5.3.2
5.3.1
5.3.0
5.2.1
5.2.0
5.1.0
5.0.2
5.0.1
5.0.0
retired
4.4.3
4.4.2
retired
4.4.1
4.4.0
4.3.0
4.2.2
4.2.1
4.2.0
4.1.0
4.0.0
3.4.4
3.4.3
3.4.2
3.4.1
3.4.0
3.3.1
3.3.0
3.2.4
3.2.3
3.2.2
3.2.1
3.2.0
3.1.0
3.0.0
2.12.2
2.12.1
retired
2.12.0
retired
2.11.0
2.10.0
2.9.1
2.9.0
2.8.0
2.7.3
2.7.2
2.7.1
2.7.0
2.6.1
2.6.0
2.5.0
2.4.0
2.3.0
2.2.2
2.2.1
2.2.0
2.1.0
2.1.0-rc.1
retired
2.1.0-rc.0
retired
2.0.4
2.0.3
2.0.2
2.0.1
2.0.0
1.1.3
1.1.2
1.1.1
retired
1.1.0
1.0.0
1.0.0-rc.0
retired
0.9.0
retired
0.8.5
retired
0.8.4
retired
0.8.3
retired
0.8.2
retired
0.8.1
retired
0.8.0
retired
0.7.0
retired
0.6.2
retired
0.6.1
retired
0.6.0
retired
0.5.3
retired
0.5.2
retired
0.5.1
retired
0.5.0
retired
0.4.3
retired
0.4.2
retired
0.4.1
retired
0.4.0
retired
0.3.0
retired
0.2.0
retired
0.1.7
retired
0.1.6
retired
0.1.5
retired
0.1.4
retired
0.1.3
retired
0.1.2
retired
0.1.1
retired
0.1.0
retired
0.0.16
retired
0.0.15
retired
0.0.14
retired
0.0.13
retired
0.0.12
retired
0.0.11
retired
0.0.10
retired
0.0.9
retired
0.0.7
retired
0.0.6
retired
0.0.5
retired
0.0.4
retired
0.0.3
retired
0.0.2
retired
Money functions for operations on and localization of a money data type with support for ISO 4217 currencies and ISO 24165 digial tokens (crypto currencies).
Current section
Files
Jump to
Current section
Files
lib/money/exchange_rates/exchange_rates_retriever.ex
defmodule Money.ExchangeRates.Retriever do @moduledoc """ Implements a `GenServer` to retrieve exchange rates from a configured retrieveal module on a periodic or on demand basis. By default exchange rates are retrieved from [Open Exchange Rates](http://openexchangerates.org). The default period of execution is 5 minutes (300_000 milliseconds). The period of retrieval is configured in `config.exs` or the appropriate environment configuration. For example: config :ex_money, retrieve_every: 300_000 """ use GenServer require Logger @etag_cache :etag_cache @doc """ Starts the exchange rates retrieval service """ def start(config \\ Money.ExchangeRates.config()) do Money.ExchangeRates.Supervisor.start_retriever(config) end @doc """ Stop the exchange rates retrieval service. The service can be restarted with `restart/0`. """ def stop do Money.ExchangeRates.Supervisor.stop_retriever() end @doc """ Restart the exchange rates retrieval service """ def restart do Money.ExchangeRates.Supervisor.restart_retriever() end @doc """ Delete the exchange rates retrieval service The service can be started again with `start/1` """ def delete do Money.ExchangeRates.Supervisor.delete_retriever() end @doc false def start_link(name, config \\ Money.ExchangeRates.config()) do GenServer.start_link(__MODULE__, config, name: name) end @doc """ Forces retrieval of the latest exchange rates Sends a message ot the exchange rate retrieval worker to request current rates be retrieved and stored. Returns: * `{:ok, rates}` if exchange rates request is successfully sent. * `{:error, reason}` if the request cannot be send. This function does not return exchange rates, for that see `Money.ExchangeRates.latest_rates/0` or `Money.ExchangeRates.historic_rates/1`. """ def latest_rates() do case Process.whereis(Money.ExchangeRates.Retriever) do nil -> {:error, exchange_rate_service_error()} _pid -> GenServer.call(__MODULE__, :latest_rates) end end @doc """ Forces retrieval of historic exchange rates for a single date * `date` is a date returned by `Date.new/3` or any struct with the elements `:year`, `:month` and `:day` or * a `Date.Range.t` created by `Date.range/2` that specifies a range of dates to retrieve Returns: * `{:ok, rates}` if exchange rates request is successfully sent. * `{:error, reason}` if the request cannot be send. Sends a message ot the exchange rate retrieval worker to request historic rates for a specified date or range be retrieved and stored. This function does not return exchange rates, for that see `Money.ExchangeRates.latest_rates/0` or `Money.ExchangeRates.historic_rates/1`. """ def historic_rates(%Date{calendar: Calendar.ISO} = date) do case Process.whereis(__MODULE__) do nil -> {:error, exchange_rate_service_error()} _pid -> GenServer.call(__MODULE__, {:historic_rates, date}) end end def historic_rates(%{year: year, month: month, day: day}) do case Date.new(year, month, day) do {:ok, date} -> historic_rates(date) error -> error end end def historic_rates(%Date.Range{first: from, last: to}) do historic_rates(from, to) end @doc """ Forces retrieval of historic exchange rates for a range of dates * `from` is a date returned by `Date.new/3` or any struct with the elements `:year`, `:month` and `:day`. * `to` is a date returned by `Date.new/3` or any struct with the elements `:year`, `:month` and `:day`. Returns: * `{:ok, rates}` if exchange rates request is successfully sent. * `{:error, reason}` if the request cannot be send. Sends a message to the exchange rate retrieval process for each date in the range `from`..`to` to request historic rates be retrieved. """ def historic_rates(%Date{calendar: Calendar.ISO} = from, %Date{calendar: Calendar.ISO} = to) do case Process.whereis(__MODULE__) do nil -> {:error, exchange_rate_service_error()} _pid -> for date <- Date.range(from, to) do historic_rates(date) end end end def historic_rates(%{year: y1, month: m1, day: d1}, %{year: y2, month: m2, day: d2}) do with {:ok, from} <- Date.new(y1, m1, d1), {:ok, to} <- Date.new(y2, m2, d2) do historic_rates(from, to) end end @doc """ Updated the configuration for the Exchange Rate Service """ def reconfigure(%Money.ExchangeRates.Config{} = config) do GenServer.call(__MODULE__, {:reconfigure, config}) end @doc """ Return the current configuration of the Exchange Rates Retrieval service """ def config do GenServer.call(__MODULE__, :config) end @doc """ Retrieve exchange rates from an external HTTP service. This function is primarily intended for use by an exchange rates api module. """ def retrieve_rates(url, config) when is_binary(url) do url |> String.to_charlist() |> retrieve_rates(config) end def retrieve_rates(url, config) when is_list(url) do headers = if_none_match_header(url) :httpc.request(:get, {url, headers}, [], []) |> process_response(url, config) end defp process_response({:ok, {{_version, 200, 'OK'}, headers, body}}, url, config) do rates = config.api_module.decode_rates(body) cache_etag(headers, url) {:ok, rates} end defp process_response({:ok, {{_version, 304, 'Not Modified'}, headers, _body}}, url, _config) do cache_etag(headers, url) {:ok, :not_modified} end defp process_response({_, {{_version, code, message}, _headers, _body}}, _url, _config) do {:error, "#{code} #{message}"} end defp process_response( {:error, {:failed_connect, [{_, {_host, _port}}, {_, _, sys_message}]}}, _url, _config ) do {:error, sys_message} end defp if_none_match_header(url) do case get_etag(url) do {etag, date} -> [ {'If-None-Match', etag}, {'If-Modified-Since', date} ] _ -> [] end end defp cache_etag(headers, url) do etag = :proplists.get_value('etag', headers) date = :proplists.get_value('date', headers) if etag?(etag, date) do :ets.insert(@etag_cache, {url, {etag, date}}) else :ets.delete(@etag_cache, url) end end defp get_etag(url) do case :ets.lookup(@etag_cache, url) do [{^url, cached_value}] -> cached_value [] -> nil end end defp etag?(etag, date) do etag != :undefined && date != :undefined end # # Server implementation # @doc false def init(config) do :erlang.process_flag(:trap_exit, true) config.cache_module.init() if is_integer(config.retrieve_every) do log(config, :info, log_init_message(config.retrieve_every)) schedule_work(0) schedule_work(config.retrieve_every) end if config.preload_historic_rates do log(config, :info, "Preloading historic rates for #{inspect(config.preload_historic_rates)}") schedule_work(config.preload_historic_rates, config.cache_module) end if :ets.info(@etag_cache) == :undefined do :ets.new(@etag_cache, [:named_table, :public]) end {:ok, config} end @doc false def terminate(:normal, config) do config.cache_module.terminate() end @doc false def terminate(:shutdown, config) do config.cache_module.terminate() end @doc false def terminate(other, _config) do Logger.error("[ExchangeRates.Retriever] Terminate called with unhandled #{inspect(other)}") end @doc false def handle_call(:latest_rates, _from, config) do {:reply, retrieve_latest_rates(config), config} end @doc false def handle_call({:historic_rates, date}, _from, config) do {:reply, retrieve_historic_rates(date, config), config} end @doc false def handle_call({:reconfigure, new_configuration}, _from, config) do config.cache_module.terminate() {:ok, new_config} = init(new_configuration) {:reply, new_config, new_config} end @doc false def handle_call(:config, _from, config) do {:reply, config, config} end @doc false def handle_call(:stop, _from, config) do {:stop, :normal, :ok, config} end @doc false def handle_call({:stop, reason}, _from, config) do {:stop, reason, :ok, config} end @doc false def handle_info(:latest_rates, config) do retrieve_latest_rates(config) schedule_work(config.retrieve_every) {:noreply, config} end @doc false def handle_info({:historic_rates, %Date{calendar: Calendar.ISO} = date}, config) do retrieve_historic_rates(date, config) {:noreply, config} end @doc false def handle_info(:stop, config) do {:stop, :normal, config} end @doc false def handle_info({:stop, reason}, config) do {:stop, reason, config} end @doc false def handle_info(message, config) do Logger.error("Invalid message for ExchangeRates.Retriever: #{inspect(message)}") {:noreply, config} end defp retrieve_latest_rates(%{callback_module: callback_module} = config) do case config.api_module.get_latest_rates(config) do {:ok, :not_modified} -> log(config, :success, "Retrieved latest exchange rates successfully. Rates unchanged.") {:ok, config.cache_module.latest_rates()} {:ok, rates} -> retrieved_at = DateTime.utc_now() config.cache_module.store_latest_rates(rates, retrieved_at) apply(callback_module, :latest_rates_retrieved, [rates, retrieved_at]) log(config, :success, "Retrieved latest exchange rates successfully") {:ok, rates} {:error, reason} -> log(config, :failure, "Could not retrieve latest exchange rates: #{inspect(reason)}") {:error, reason} end end defp retrieve_historic_rates(date, %{callback_module: callback_module} = config) do case config.api_module.get_historic_rates(date, config) do {:ok, :not_modified} -> log(config, :success, "Historic exchange rates for #{Date.to_string(date)} are unchanged") {:ok, config.cache_module.historic_rates(date)} {:ok, rates} -> config.cache_module.store_historic_rates(rates, date) apply(callback_module, :historic_rates_retrieved, [rates, date]) log( config, :success, "Retrieved historic exchange rates for #{Date.to_string(date)} successfully" ) {:ok, rates} {:error, reason} -> log( config, :failure, "Could not retrieve historic exchange rates " <> "for #{Date.to_string(date)}: #{inspect(reason)}" ) {:error, reason} end end defp schedule_work(delay_ms) when is_integer(delay_ms) do Process.send_after(self(), :latest_rates, delay_ms) end defp schedule_work(%Date.Range{} = date_range, cache_module) do for date <- date_range do schedule_work(date, cache_module) end end # Don't retrieve historic rates if they are # already cached. Note that this is only # called at retriever initialization, not # through the public api. # # This depends on: # 1. The cache is persistent, like Cache.Dets # 2. The assumption that historic rates don't change # A persistent cache will reduce the number of # external API calls and it means the cache # will survive restarts both intentional and # unintentional defp schedule_work(%Date{calendar: Calendar.ISO} = date, cache_module) do case cache_module.historic_rates(date) do {:ok, _rates} -> :ok {:error, _} -> Process.send(self(), {:historic_rates, date}, []) end end defp schedule_work({%Date{} = from, %Date{} = to}, cache_module) do schedule_work(Date.range(from, to), cache_module) end defp schedule_work(date_string, cache_module) when is_binary(date_string) do parts = String.split(date_string, "..") case parts do [date] -> schedule_work(Date.from_iso8601(date), cache_module) [from, to] -> schedule_work({Date.from_iso8601(from), Date.from_iso8601(to)}, cache_module) end end # Any non-numeric value, or non-date value means # we don't schedule work - ie there is no periodic # retrieval defp schedule_work(_, _cache_module) do :ok end @doc false def log(%{log_levels: log_levels}, key, message) do case Map.get(log_levels, key) do nil -> nil log_level -> Logger.log(log_level, message) end end defp log_init_message(every) do {every, plural_every} = seconds(every) "Exchange Rates will be retrieved now and then every #{every} #{plural_every}." end defp seconds(milliseconds) do seconds = div(milliseconds, 1000) plural = if seconds == 1, do: "second", else: "seconds" {seconds, plural} end defp exchange_rate_service_error do {Money.ExchangeRateError, "Exchange rate service does not appear to be running"} endend