lib/cache_money.ex

defmodule CacheMoney do
  @moduledoc """
  Handles caching values under different cache names, can expire keys
  """

  use GenServer

  @default_timeout 5000

  @typedoc """
  The name of the cache, used for namespacing multiple caches on the same adapter.
  Can be either a binary or an atom, but will always be converted to a binary.
  """
  @type cache_name :: binary | atom

  @typedoc """
  The key a value will be set under. Can be either a binary or an atom, but will
  always be converted to a binary.
  """
  @type key :: binary | atom

  @typedoc """
  The value to be saved in the cache. Can be any value going _in_ to the cache,
  but depending on the adapter used, may not be the same value going out. For
  example, `CacheMoney.Adapters.ETS` can save any elixir term, including `pid`s.
  `CacheMoney.Adapters.Redis`, however, can only save items as strings.
  """
  @type value :: term

  @typedoc """
  Currently the only option available is an optional `timeout` that gets passed
  along with `GenServer.call`
  """
  @type options :: [timeout: integer]

  @type lazy_function :: (() -> {:ok, value} | {:ok, value, integer} | {:error, value} | value)

  @type server :: GenServer.server()

  @doc """
  Starts a `CacheMoney` process linked to the current process.

  ## Arguments

  * cache - the name of the cache. Multiple caches using the same adapter will
    all be in the same spot, but will be namespaced by the given cache name.
  * conifg - contains various configuration options for the cache, depending on
    the adapter. `:adapter` is required to be set, and must be set to a module that
    implements `CacheMoney.Adapter`, such as `CacheMoney.Adapters.Redis` or
    `CacheMoney.Adapters.ETS`. Different adapters will also specify other required
    optionso be passed to them through the `config` argument
  * opts - see `GenServer.start_link/3`. Options are passed straight through to the
    underlying `GenServer`
  """
  @spec start_link(cache_name, map(), Keyword.t()) :: GenServer.on_start()
  def start_link(cache, config = %{}, opts \\ []) do
    config =
      config
      |> Map.put_new(:cache, cache)
      |> config.adapter.start_link()

    opts = Keyword.put_new(opts, :name, cache)

    GenServer.start_link(__MODULE__, config, opts)
  end

  @doc """
  Gets the value out of the cache using the `key`.

  If the value does not exist in the cache `nil` will be returned.
  """
  @spec get(server, key, options()) :: {:ok, value} | {:error, term}
  def get(server, key, opts \\ []),
    do: GenServer.call(server, {:get, key}, opts[:timeout] || @default_timeout)

  @doc """
  Gets the value out of the cache using the `key`. Lazily fetches the data, inserts
  it into the cache, and returns it if it does not exist. Optional `expiry` is in
  seconds.
  """
  @spec get_lazy(server, key, lazy_function(), integer | nil, options()) ::
          {:ok, value} | {:error, any}
  def get_lazy(server, key, fun, expiry \\ nil, opts \\ []),
    do: GenServer.call(server, {:get_lazy, key, fun, expiry}, opts[:timeout] || @default_timeout)

  @doc """
  Sets `key` in the cache to `value`
  """
  @spec set(server, key, value) :: {:ok, value} | {:error, any}
  def set(server, key, value), do: GenServer.call(server, {:set, key, value})

  @doc """
  Sets `key` in the cache to `value`
  """
  @spec set(server, key, value, options()) :: {:ok, value} | {:error, any}
  def set(server, key, value, opts) when is_list(opts),
    do: GenServer.call(server, {:set, key, value}, opts[:timeout] || @default_timeout)

  @doc """
  Sets `key` in the cache to `value`, which expires after `expiry` seconds
  """
  @spec set(server, key, value, integer, options()) :: {:ok, value} | {:error, any}
  def set(server, key, value, expiry, opts \\ []),
    do: GenServer.call(server, {:set, key, value, expiry}, opts[:timeout] || @default_timeout)

  @doc """
  Deletes the `key` from the cache
  """
  @spec delete(server, key, options()) :: {:ok, value} | {:error, term}
  def delete(server, key, opts \\ []),
    do: GenServer.call(server, {:delete, key}, opts[:timeout] || @default_timeout)

  @impl true
  def init(args) do
    {:ok, args}
  end

  @impl true
  def handle_call({:get, key}, from, config) do
    key = get_key(config.cache, key)
    {:reply, config.adapter.get(with_caller(config, from), key), config}
  end

  def handle_call({:get_lazy, key, fun, expiry}, from, config) do
    key = get_key(config.cache, key)

    case config.adapter.get(with_caller(config, from), key) do
      {:ok, nil} ->
        value = get_and_save_lazy_value(key, fun.(), expiry, with_caller(config, from))
        {:reply, value, config}

      value ->
        {:reply, value, config}
    end
  end

  def handle_call({:set, key, value}, from, config) do
    {:reply, config.adapter.set(with_caller(config, from), get_key(config.cache, key), value),
     config}
  end

  def handle_call({:set, key, value, expiry}, from, config) do
    {:reply,
     config.adapter.set(
       with_caller(config, from),
       get_key(config.cache, key),
       value,
       expiry
     ), config}
  end

  def handle_call({:delete, key}, from, config) do
    {:reply, config.adapter.delete(with_caller(config, from), get_key(config.cache, key)), config}
  end

  defp get_and_save_lazy_value(key, {:ok, value}, nil, config) do
    config.adapter.set(config, key, value)
    {:ok, value}
  end

  defp get_and_save_lazy_value(key, {:ok, value, expiry}, nil, config) do
    config.adapter.set(config, key, value, expiry)
    {:ok, value}
  end

  defp get_and_save_lazy_value(key, {:ok, value}, expiry, config) do
    config.adapter.set(config, key, value, expiry)
    {:ok, value}
  end

  defp get_and_save_lazy_value(_key, {:error, error}, _, _config), do: {:error, error}

  defp get_and_save_lazy_value(key, value, nil, config) do
    config.adapter.set(config, key, value)
    {:ok, value}
  end

  defp get_and_save_lazy_value(key, value, expiry, config) do
    config.adapter.set(config, key, value, expiry)
    {:ok, value}
  end

  defp get_key(cache, key) do
    "#{cache}-#{key}"
  end

  defp with_caller(config, {caller, _}), do: Map.put(config, :caller, caller)
end