lib/hush/resolver.ex

defmodule Hush.Resolver do
  require Logger

  @moduledoc """
  Replace configuration with provider-aware resolvers.
  """

  alias Hush.{Provider, Transformer}

  @doc """
  Substitute {:hush, Hush.Provider, "key", [options]} present in the the config argument.
  """
  @spec resolve(Keyword.t(), Keyword.t()) :: {:ok, Keyword.t()} | {:error, any()}
  def resolve(config, options \\ Keyword.new()) do
    try do
      {:ok, resolve!(config, options)}
    rescue
      e in RuntimeError -> {:error, e.message}
    end
  end

  @doc """
  Substitute {:hush, Hush.Provider, "key", [options]} present in the the config argument
  """
  @spec resolve!(Keyword.t() | map, Keyword.t()) :: Keyword.t()
  def resolve!(config, options \\ Keyword.new()) do
    options =
      options
      |> Keyword.take([:max_concurrency, :timeout])
      |> Keyword.put(:on_timeout, :kill_task)
      |> Keyword.put(:ordered, false)

    cache = config |> build_cache(options)
    config |> reduce(values(cache))
  end

  defp build_cache(config, options) do
    config
    |> reduce(&flatten/2)
    |> Stream.uniq_by(fn {provider, key, _} -> hash(provider, key) end)
    |> Task.async_stream(fn {p, k, _} -> {hash(p, k), fetch(p, k)} end, options)
    |> Enum.reduce(%{}, fn
      {:ok, {k, v}}, acc ->
        Map.put_new(acc, k, v)

      _, acc ->
        """
        A timeout occurred resolving a key from a provider while warming the cache. Although this is not an issue if its isolated, but if there are multiple of these warnings you should consider increasing the timeout from the default of 5_000 milliseconds:

          config :hush,
            timeout: 10_000
        """
        |> Logger.warn()

        acc
    end)
  end

  defp value!(provider, name, options, cache) do
    case value(provider, name, options, cache) do
      {:ok, value} ->
        value

      {:error, error} ->
        raise RuntimeError,
          message: "Could not resolve {:hush, #{provider}, #{inspect(name)}}: #{error}"
    end
  end

  defp value(provider, key, options, cache) do
    with {:ok, value} <- fetch(provider, key, cache),
         {:ok, value} <- Transformer.apply(options, value) do
      {:ok, value}
    else
      {:error, :not_found} ->
        default_or_error(options)

      {:error, error} when is_binary(error) ->
        {:error, error}

      {:error, error} ->
        {:error, inspect(error)}
    end
  end

  defp default_or_error(options) do
    cond do
      # lets get default if it exists
      Keyword.has_key?(options, :default) ->
        {:ok, Keyword.get(options, :default)}

      # return nil if optional is set
      Keyword.get(options, :optional, false) ->
        {:ok, nil}

      # return error in any other case
      true ->
        {:error,
         "The provider couldn't find a value for this key. If this is an optional key, you add `optional: true` to the options list."}
    end
  end

  defp fetch(provider, key, cache \\ %{}) do
    case Map.get(cache, hash(provider, key), nil) do
      nil ->
        try do
          Provider.fetch(provider, key)
        rescue
          error -> {:error, Exception.message(error)}
        end

      cached ->
        cached
    end
  end

  defp flatten({:hush, provider, name}, acc), do: acc ++ [{provider, name, []}]
  defp flatten({:hush, provider, name, opts}, acc), do: acc ++ [{provider, name, opts}]
  defp flatten(it, acc) when is_list(it), do: acc ++ reduce(it, &flatten/2)
  defp flatten(it, acc) when is_tuple(it), do: acc ++ (Tuple.to_list(it) |> reduce(&flatten/2))
  defp flatten(it, acc) when is_map(it) and not is_struct(it), do: acc ++ reduce(it, &flatten/2)
  defp flatten(_, acc), do: acc

  defp hash(provider, key), do: "#{provider}.#{key}"
  defp reduce(config, reducer), do: Enum.reduce(config, [], reducer)

  defp values(cache) do
    fn
      {:hush, provider, name}, acc ->
        acc ++ [value!(provider, name, [], cache)]

      {:hush, provider, name, opts}, acc ->
        acc ++ [value!(provider, name, opts, cache)]

      it, acc when is_list(it) ->
        acc ++ [reduce(it, values(cache))]

      it, acc when is_tuple(it) ->
        acc ++ [Tuple.to_list(it) |> reduce(values(cache)) |> List.to_tuple()]

      it, acc when is_map(it) and not is_struct(it) ->
        acc ++ [reduce(it, values(cache)) |> Map.new()]

      it, acc ->
        acc ++ [it]
    end
  end
end