Packages
ex_money
6.1.0
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/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