Packages

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
ex_money lib money exchange_rates retriever.ex
Raw

lib/money/exchange_rates/retriever.ex

defmodule Money.ExchangeRates.Retriever do
@moduledoc """
A `GenServer` that retrieves exchange rates from a configured API module on a
periodic or on-demand basis.
Add it to your application's supervision tree to enable the exchange rates
service:
children = [
MyApp.Repo,
Money.ExchangeRates.Retriever
]
To start with a custom configuration:
children = [
{Money.ExchangeRates.Retriever, [config: my_config]}
]
Multiple named retrievers can be started independently, each backed by a
different API source:
children = [
{Money.ExchangeRates.Retriever, [name: :open_exchange_rates, config: oxr_config]},
{Money.ExchangeRates.Retriever, [name: :fixer, config: fixer_config]}
]
Money.ExchangeRates.Retriever.latest_rates(:open_exchange_rates)
Money.ExchangeRates.Retriever.historic_rates(:fixer, ~D[2024-01-01])
> #### Each named retriever needs its own cache module {: .warning}
>
> The bundled cache implementations (`Money.ExchangeRates.Cache.Ets` and
> `Money.ExchangeRates.Cache.Dets`) use a fixed, module-wide storage location
> (the `:exchange_rates` ETS table / a single DETS file). Two retrievers
> configured with the same `cache_module` therefore share one cache and will
> overwrite each other's rates. When running multiple named retrievers, give
> each its own `cache_module` (a distinct module backed by a distinct ETS
> table name or DETS path) in its configuration.
By default exchange rates are retrieved from
[Open Exchange Rates](http://openexchangerates.org). The retrieval interval
is configured via the `:exchange_rates_retrieve_every` key (milliseconds):
config :ex_money,
exchange_rates_retrieve_every: 300_000
"""
use GenServer
require Logger
@doc deprecated: "Use `Supervisor.start_child/2` on your application's supervisor instead"
def start(name \\ __MODULE__, config \\ Money.ExchangeRates.config()) do
GenServer.start_link(__MODULE__, config, name: name)
end
@doc deprecated: "Use `Supervisor.terminate_child/2` on your application's supervisor instead"
def stop(retriever \\ __MODULE__) do
GenServer.stop(retriever)
end
@doc deprecated: "Use `Supervisor.restart_child/2` on your application's supervisor instead"
def restart(retriever \\ __MODULE__) do
if pid = GenServer.whereis(retriever), do: GenServer.stop(pid)
start(retriever)
end
@doc deprecated: "Use `Supervisor.delete_child/2` on your application's supervisor instead"
def delete(retriever \\ __MODULE__) do
stop(retriever)
end
@doc false
def start_link(opts \\ []) do
config = Keyword.get(opts, :config, Money.ExchangeRates.config())
name = Keyword.get(opts, :name, __MODULE__)
GenServer.start_link(__MODULE__, config, name: name)
end
@doc """
Forces retrieval of the latest exchange rates
Sends a message to 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 sent.
This function does not return exchange rates, for that see
`Money.ExchangeRates.latest_rates/0` or
`Money.ExchangeRates.historic_rates/1`.
"""
@spec latest_rates(GenServer.server()) :: {:ok, map()} | {:error, {Exception.t(), binary}}
def latest_rates(retriever \\ __MODULE__) do
case Process.whereis(retriever) do
nil -> {:error, exchange_rate_service_error()}
pid -> GenServer.call(pid, :latest_rates)
end
end
@doc """
Forces retrieval of historic exchange rates for a single date
* `date` is a `t:Date.t/0` or any date-compatible map or struct (`t:Calendar.date/0`) 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 sent.
Sends a message to 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`.
"""
@spec historic_rates(Calendar.date()) :: {:ok, map()} | {:error, {Exception.t(), binary}}
@spec historic_rates(Date.Range.t()) ::
[{:ok, map()} | {:error, {Exception.t(), binary}}] | {:error, {Exception.t(), binary}}
def historic_rates(date_or_range) when is_map(date_or_range) do
historic_rates(__MODULE__, date_or_range)
end
@spec historic_rates(GenServer.server(), Calendar.date()) ::
{:ok, map()} | {:error, {Exception.t(), binary}}
def historic_rates(retriever, %Date{calendar: Calendar.ISO} = date)
when is_atom(retriever) or is_pid(retriever) do
case Process.whereis(retriever) do
nil -> {:error, exchange_rate_service_error()}
pid -> GenServer.call(pid, {:historic_rates, date})
end
end
def historic_rates(retriever, %{year: year, month: month, day: day})
when is_atom(retriever) or is_pid(retriever) do
case Date.new(year, month, day) do
{:ok, date} -> historic_rates(retriever, date)
error -> error
end
end
@spec historic_rates(GenServer.server(), Date.Range.t()) ::
[{:ok, map()} | {:error, {Exception.t(), binary}}] | {:error, {Exception.t(), binary}}
def historic_rates(retriever, %Date.Range{} = range) do
case Process.whereis(retriever) do
nil -> {:error, exchange_rate_service_error()}
_pid -> for date <- range, do: historic_rates(retriever, date)
end
end
@doc """
Forces retrieval of historic exchange rates for a range of dates
* `from` is a `t:Date.t/0` or any date-compatible map or struct (`t:Calendar.date/0`).
* `to` is a `t:Date.t/0` or any date-compatible map or struct (`t:Calendar.date/0`).
Returns:
* `{:ok, rates}` if exchange rates request is successfully sent.
* `{:error, reason}` if the request cannot be sent.
Sends a message to the exchange rate retrieval process for each
date in the range `from`..`to` to request historic rates be
retrieved.
"""
@spec historic_rates(Calendar.date(), Calendar.date()) ::
[{:ok, map()} | {:error, {Exception.t(), binary}}] | {:error, {Exception.t(), binary}}
def historic_rates(from, to) when is_map(from) and is_map(to) do
historic_rates(__MODULE__, from, to)
end
@spec historic_rates(GenServer.server(), Calendar.date(), Calendar.date()) ::
[{:ok, map()} | {:error, {Exception.t(), binary}}] | {:error, {Exception.t(), binary}}
def historic_rates(
retriever,
%Date{calendar: Calendar.ISO} = from,
%Date{calendar: Calendar.ISO} = to
) do
range = Date.range(from, to)
historic_rates(retriever, range)
end
def historic_rates(retriever, %{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(retriever, from, to)
end
end
@doc """
Returns `true` if the latest exchange rates are available in the cache,
`false` otherwise.
Returns `false` when the retriever is not running, even if the cache table
still exists.
"""
@spec latest_rates_available?(GenServer.server()) :: boolean
def latest_rates_available?(retriever \\ __MODULE__) do
case Process.whereis(retriever) do
nil -> false
pid -> GenServer.call(pid, :latest_rates_available?)
end
end
@doc """
Returns the timestamp of the last successful exchange rate retrieval.
Returns:
* `{:ok, datetime}` if rates have been retrieved at least once.
* `{:error, reason}` if the retriever is not running or no retrieval has
occurred yet.
"""
@spec last_updated(GenServer.server()) :: {:ok, DateTime.t()} | {:error, {Exception.t(), binary}}
def last_updated(retriever \\ __MODULE__) do
case Process.whereis(retriever) do
nil -> {:error, exchange_rate_service_error()}
pid -> GenServer.call(pid, :last_updated)
end
end
@doc """
Updates the configuration for the Exchange Rate
Service
"""
def reconfigure(retriever \\ __MODULE__, %Money.ExchangeRates.Config{} = config) do
case Process.whereis(retriever) do
nil -> {:error, exchange_rate_service_error()}
pid -> GenServer.call(pid, {:reconfigure, config})
end
end
@doc """
Returns the current configuration of the Exchange Rates
Retrieval service
"""
def config(retriever \\ __MODULE__) do
case Process.whereis(retriever) do
nil -> {:error, exchange_rate_service_error()}
pid -> GenServer.call(pid, :config)
end
end
@doc deprecated:
"Use `Money.ExchangeRates.HTTP` or the HTTP client of your preference directly instead"
def retrieve_rates(url, config) when is_list(url) do
url
|> List.to_string()
|> retrieve_rates(config)
end
def retrieve_rates(url, config) when is_binary(url) do
url
|> Money.ExchangeRates.HTTP.get(verify_peer: Map.get(config, :verify_peer, true))
|> process_response(config)
end
defp process_response({:ok, body}, config) when is_binary(body) or is_list(body) do
{:ok, config.api_module.decode_rates(body)}
end
defp process_response({:ok, :not_modified}, _config) do
{:ok, :not_modified}
end
defp process_response({:error, reason}, _config) do
{:error, reason}
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_latest_rates_fetch(0)
end
if config.preload_historic_rates do
log(config, :info, "Preloading historic rates for #{inspect(config.preload_historic_rates)}")
schedule_historic_rates_preload(config.preload_historic_rates, config.cache_module)
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
def handle_call(:latest_rates_available?, _from, config) do
{:reply, match?({:ok, _rates}, config.cache_module.latest_rates()), config}
end
def handle_call(:last_updated, _from, config) do
{:reply, config.cache_module.last_updated(), 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(:scheduled_latest_rates_fetch, config) do
fetch_latest_rates(config)
schedule_latest_rates_fetch(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(config) do
case config.cache_module.latest_rates() do
{:ok, rates} -> {:ok, rates}
{:error, _reason} -> fetch_latest_rates(config)
end
end
defp fetch_latest_rates(config) do
case config.api_module.get_latest_rates(config) do
{:ok, :not_modified} ->
log(config, :success, "Retrieved latest exchange rates successfully. Rates unchanged.")
config.cache_module.latest_rates()
{:ok, rates} ->
retrieved_at = DateTime.utc_now()
config.cache_module.store_latest_rates(rates, retrieved_at)
apply(config.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, config) do
case config.cache_module.historic_rates(date) do
{:ok, rates} -> {:ok, rates}
{:error, _reason} -> fetch_historic_rates(date, config)
end
end
defp fetch_historic_rates(date, 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)} unchanged")
config.cache_module.historic_rates(date)
{:ok, rates} ->
config.cache_module.store_historic_rates(rates, date)
apply(config.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_latest_rates_fetch(delay_ms) when is_integer(delay_ms) do
Process.send_after(self(), :scheduled_latest_rates_fetch, delay_ms)
end
defp schedule_historic_rates_preload(%Date.Range{} = date_range, cache_module) do
for date <- date_range do
schedule_historic_rates_preload(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_historic_rates_preload(%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_historic_rates_preload({%Date{} = from, %Date{} = to}, cache_module) do
schedule_historic_rates_preload(Date.range(from, to), cache_module)
end
defp schedule_historic_rates_preload(date_string, cache_module) when is_binary(date_string) do
parts = String.split(date_string, "..")
case parts do
[date] ->
schedule_historic_rates_preload(Date.from_iso8601(date), cache_module)
[from, to] ->
schedule_historic_rates_preload(
{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_historic_rates_preload(_, _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} =
Localize.Number.to_string(seconds)
{formatted_seconds, plural}
end
defp exchange_rate_service_error do
{Money.ExchangeRateError, "Exchange rate service does not appear to be running"}
end
end