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
    headers = if_none_match_header(url)

    :httpc.request(:get, {url, headers}, https_opts(config, url), [])
    |> 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, {Money.ExchangeRateError, "#{code} #{message}"}}
  end

  defp process_response(
         {:error, {:failed_connect, [{_, {_host, _port}}, {_, _, sys_message}]}},
         url,
         _config
       ) do
    {:error, {Money.ExchangeRateError, "Failed to connect to #{url}: #{inspect(sys_message)}"}}
  end

  defp process_response({:error, {:tls_alert, {:certificate_expired, _message}}}, url, _config) do
    {:error, {Money.ExchangeRateError, "Certificate for #{inspect(url)} has expired"}}
  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"}
  end

  #### Certificate verification

  @certificate_locations [
                           # Configured cacertfile
                           Application.get_env(Cldr.Config.app_name(), :cacertfile),

                           # Populated if hex package CAStore is configured
                           if(Code.ensure_loaded?(CAStore), do: CAStore.file_path()),

                           # Populated if hex package certfi is configured
                           if(Code.ensure_loaded?(:certifi),
                             do: :certifi.cacertfile() |> List.to_string()
                           ),

                           # Debian/Ubuntu/Gentoo etc.
                           "/etc/ssl/certs/ca-certificates.crt",

                           # Fedora/RHEL 6
                           "/etc/pki/tls/certs/ca-bundle.crt",

                           # OpenSUSE
                           "/etc/ssl/ca-bundle.pem",

                           # OpenELEC
                           "/etc/pki/tls/cacert.pem",

                           # CentOS/RHEL 7
                           "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",

                           # Open SSL on MacOS
                           "/usr/local/etc/openssl/cert.pem",

                           # MacOS, OpenBSD & Alpine Linux
                           "/etc/ssl/cert.pem"
                         ]
                         |> Enum.reject(&is_nil/1)

  @doc """
  Returns the certificate store to be used when
  retrieving exchange rates.

  """
  def certificate_store do
    @certificate_locations
    |> Enum.find(&File.exists?/1)
    |> raise_if_no_cacertfile
    |> :erlang.binary_to_list()
  end

  defp raise_if_no_cacertfile(nil) do
    raise RuntimeError, """
    No certificate trust store was found.
    Tried looking for: #{inspect(@certificate_locations)}

    A certificate trust store is required in
    order to download locales for your configuration.

    Since ex_cldr could not detect a system
    installed certificate trust store one of the
    following actions may be taken:

    1. Install the hex package `castore`. It will
       be automatically detected after recompilation.

    2. Install the hex package `certifi`. It will
       be automatically detected after recomilation.

    3. Specify the location of a certificate trust store
       by configuring it in `config.exs`:

       config :ex_cldr,
         cacertfile: "/path/to/cacertfile",
         ...

    """
  end

  defp raise_if_no_cacertfile(file) do
    file
  end

  # See https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/ssl.html

  @otp_version :otp_release |> :erlang.system_info() |> List.to_integer()

  if @otp_version > 21 do
    defp https_opts(%Money.ExchangeRates.Config{verify_peer: true}, _url) do
      [
        ssl: [
          verify: :verify_peer,
          cacertfile: certificate_store(),
          depth: 99,
          log_level: :alert,
          log_alert: true,
          customize_hostname_check: [
            match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
          ]
        ]
      ]
    end
  else
    defp https_opts(%Money.ExchangeRates.Config{verify_peer: true}, url) do
      host = url |> :uri_string.parse() |> Map.fetch!(:host)

      [
        ssl: [
          verify: :verify_peer,
          verify_fun: {&:ssl_verify_hostname.verify_fun/3, check_hostname: host},
          cacertfile: certificate_store(),
          server_name_indication: host,
          reuse_sessions: false,
          depth: 99
        ]
      ]
    end
  end

  defp https_opts(%Money.ExchangeRates.Config{verify_peer: false}, _url) do
    []
  end
end