Packages
ex_money
5.16.1
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(__MODULE__) 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
url = List.to_string(url)
headers = if_none_match_header(url)
{url, headers}
|> Cldr.Http.get_with_headers(verify_peer: Map.get(config, :verify_peer, true))
|> process_response(url, config)
end
defp process_response({:ok, headers, body}, url, config) do
rates = config.api_module.decode_rates(body)
cache_etag(headers, url)
{:ok, rates}
end
defp process_response({:not_modified, headers}, url, _config) do
cache_etag(headers, url)
{:ok, :not_modified}
end
defp process_response({:error, reason}, _url, _config) do
{:error, {Money.ExchangeRateError, "#{inspect reason}"}}
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)
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"
{:ok, formatted_seconds} = Cldr.Number.to_string(seconds, backend: Money.get_env(:default_cldr_backend))
{formatted_seconds, plural}
end
defp exchange_rate_service_error do
{Money.ExchangeRateError, "Exchange rate service does not appear to be running"}
end
end