lib/fun_server.ex

defmodule FunServer do
  @moduledoc """
  `FunServer` is a GenServer in which
  instead of passing parameters to `GenServer.call/3` or `GenServer.cast/2`
  and then writing the corresponding callbacks with the necessary functionality
  you pass functions (i.e. `handlers`).

  Essentially `FunServer` is just a simple wrapper over `GenServer`, which takes the approach of
  passing down functions instead of messages.

  ## Example
  Basically instead of using `init`, `handle_continue`, `handle_call` or `handle_cast` callbacks,
  functions are being passed that get executed in the corresponding callback.

  This is an example of a simple `FunServer` Stack Server.

      defmodule Server do
        use FunServer

        def start_link(_args) do
          FunServer.start_link(__MODULE__, [name: __MODULE__], fn -> {:ok, args} end)
        end

        def push(value) do
          FunServer.async(__MODULE__, fn state ->
            {:noreply, [value | state]}
          end)
        end

        def pop(value) do
          FunServer.sync(__MODULE__, fn _from, [value | new_state] ->
            {:noreply, value, new_state}
          end)
        end
      end

  The callbacks `FunServer` wraps around are the following:
    - `init/1`
    - `handle_call/3`
    - `handle_cast/2`
    - `handle_continue/2`

  The rest of the callbacks for `GenServer` can be handled normally:
    - `handle_info/2`
    - `terminate/2`
    - `code_change/3`
    - `format_status/2`
  """

  @type reply :: any()
  @type state :: any()
  @type new_state :: any()
  @type reason :: any()

  @type async_handler ::
          mfa()
          | (state() ->
               {:noreply, new_state()}
               | {:noreply, new_state(), timeout() | :hibernate | {:continue, async_handler()}}
               | {:stop, reason(), new_state()})

  @type sync_handler ::
          mfa()
          | (from :: GenServer.from(), state() ->
               {:reply, reply(), new_state()}
               | {:reply, reply(), new_state(),
                  timeout() | :hibernate | {:continue, async_handler()}}
               | {:noreply, new_state()}
               | {:noreply, new_state(), timeout() | :hibernate | {:continue, async_handler()}}
               | {:stop, reason(), reply(), new_state()}
               | {:stop, reason(), new_state()})

  @type init_handler ::
          (() -> {:ok, state()}
                 | {:ok, state(), timeout() | :hibernate | {:continue, async_handler()}}
                 | :ignore
                 | {:stop, reason()})

  # defguard is_server({pid, node}) when is_atom(pid) and is_atom(node)
  defguard is_server(server) when is_tuple(server) or is_atom(server) or is_pid(server)

  @doc false
  defmacro __using__(opts) do
    quote do
      use GenServer, unquote(opts)

      @impl true
      def init({m, f, a}) when is_atom(m) and is_atom(f) and is_list(a) do
        apply(m, f, a)
      end

      @impl true
      def init(f) when is_function(f) do
        f.()
      end

      @impl true
      def handle_continue({m, f, a}, state) when is_atom(m) and is_atom(f) and is_list(a) do
        apply(m, f, a).(state)
      end

      @impl true
      def handle_continue(f, state) when is_function(f) do
        f.(state)
      end

      @impl true
      def handle_call({m, f, a}, from, state) when is_atom(m) and is_atom(f) and is_list(a) do
        apply(m, f, a).(from, state)
      end

      @impl true
      def handle_call(f, from, state) when is_function(f) do
        f.(from, state)
      end

      @impl true
      def handle_cast({m, f, a}, state) when is_atom(m) and is_atom(f) and is_list(a) do
        apply(m, f, a).(state)
      end

      @impl true
      def handle_cast(f, state) when is_function(f) do
        f.(state)
      end
    end
  end

  @doc """
  Starts a `FunServer` process without links (outside of a supervision tree)

  _For more information please refer to `GenServer.start/3`_

  ## Example
      iex> FunServer.start(MyFunServer, [name: MyFunServer], fn -> {:ok, []} end)
  """
  @spec start(module(), GenServer.options(), init_handler()) ::
          {:ok, pid()} | {:error, any()} | :ignore
  def start(module, options \\ [], init_handler)

  def start(module, options, {m, f, a} = handler) when is_atom(m) and is_atom(f) and is_list(a) do
    GenServer.start(module, handler, options)
  end

  def start(module, options, handler) when is_function(handler) do
    GenServer.start(module, handler, options)
  end

  @doc """
  Starts a `FunServer` process linked to the current 'caller' process.

  _For more information please refer to `GenServer.start_link/3`_

  ## Example
      iex> FunServer.start_link(MyFunServer, [name: MyFunServer], fn -> {:ok, []} end)
  """
  @spec start_link(module(), GenServer.options(), init_handler()) ::
          {:ok, pid()} | {:error, any()} | :ignore
  def start_link(module, options \\ [], init_handler)

  def start_link(module, options, {m, f, a} = handler)
      when is_atom(m) and is_atom(f) and is_list(a) do
    GenServer.start_link(module, handler, options)
  end

  def start_link(module, options, handler) when is_function(handler) do
    GenServer.start_link(module, handler, options)
  end

  @doc """
  Synchronously stops the server with the given `reason`.

  _For more information please refer to `GenServer.stop/3`_
  """
  @spec stop(GenServer.server(), reason :: any(), timeout()) :: :ok
  def stop(server, reason \\ :normal, timeout \\ :infinity) do
    GenServer.stop(server, reason, timeout)
  end

  @doc """
  Calls all servers locally registered as name at the specified nodes.

  _For more information please refer to `GenServer.multi_call/4`_
  """
  @spec multi_call(
          nodes :: [node()],
          name :: atom(),
          request :: any(),
          timeout :: timeout()
        ) ::
          {replies :: [{node(), any()}], bad_nodes :: [node()]}
  def multi_call(nodes \\ [node() | Node.list()], name, request, timeout \\ :infinity) do
    GenServer.multi_call(nodes, name, request, timeout)
  end

  @doc """
  Casts all servers locally registered as name at the specified nodes.

  _For more information please refer to `GenServer.abcast/3`_
  """
  @spec abcast(nodes :: [node()], name :: atom(), request :: any()) :: :abcast
  def abcast(nodes \\ [node() | Node.list()], name, request) do
    GenServer.abcast(nodes, name, request)
  end

  @doc """
  Replies to a client.

  _For more information please refer to `GenServer.reply/2`_
  """
  @spec reply(from :: GenServer.from(), reply :: any()) :: :ok
  def reply(client, reply) do
    GenServer.reply(client, reply)
  end

  @doc """
  Executes a synchronous call to the `server` and waits for a reply.
  Works very similar to how a `GenServer.call/3` function works, but instead of passing message
  which is later handled in a `handle_call/3` callback, a function is passed, which gets evaluated
  on the `server`.

  _For additional information please refer to `GenServer.call/3`_
  """
  @spec sync(GenServer.server(), timeout(), sync_handler()) :: any()
  def sync(server, timeout \\ 5_000, handler)

  def sync(server, timeout, {m, f, a} = handler)
      when is_atom(m) and is_atom(f) and is_list(a) and is_server(server) and is_integer(timeout) do
    GenServer.call(server, handler, timeout)
  end

  def sync(server, timeout, handler)
      when is_function(handler) and is_server(server) and is_integer(timeout) do
    GenServer.call(server, handler, timeout)
  end

  @doc """
  Executes an asynchronous call to the `server`.
  Works very similar to how a `GenServer.cast/2` function works, but instead of passing message
  which is later handled in a `handle_cast/2` callback, a function is passed, which gets evaluated
  on the `server`.

  _For additional information please refer to `GenServer.cast/2`_
  """
  @spec async(GenServer.server(), async_handler()) :: any()
  def async(server, {m, f, a} = handler)
      when is_atom(m) and is_atom(f) and is_list(a) and is_server(server) do
    GenServer.cast(server, handler)
  end

  def async(server, handler) when is_function(handler) and is_server(server) do
    GenServer.cast(server, handler)
  end
end