lib/pluggable.ex

defmodule Pluggable do
  @moduledoc """
  The step specification.

  There are two kind of steps: function steps and module steps.

  #### Function steps

  A function step is any function that receives a token and a set of
  options and returns a token. Its type signature must be:

      (Pluggable.Token.t, Pluggable.opts) :: Pluggable.Token.t

  #### Module steps

  A module step is an extension of the function step. It is a module that must
  export:

    * a `c:call/2` function with the signature defined above
    * an `c:init/1` function which takes a set of options and initializes it.

  The result returned by `c:init/1` is passed as second argument to `c:call/2`. Note
  that `c:init/1` may be called during compilation and as such it must not return
  pids, ports or values that are specific to the runtime.

  The API expected by a module step is defined as a behaviour by the
  `Pluggable` module (this module).

  ## Examples

  Here's an example of a function step:

      def json_header_step(token, _opts) do
        My.Token.put_data(token, "some_data")
      end

  Here's an example of a module step:

      defmodule PutSomeData do
        def init(opts) do
          opts
        end

        def call(token, _opts) do
          My.Token.put_data(token, "some_data")
        end
      end

  ## The Pluggable Step pipeline

  The `Pluggable.StepBuilder` module provides conveniences for building
  pluggable step pipelines.
  """

  @type opts ::
          binary
          | tuple
          | atom
          | integer
          | float
          | [opts]
          | %{optional(opts) => opts}
          | MapSet.t()

  @callback init(opts) :: opts
  @callback call(token :: Pluggable.Token.t(), opts) :: Pluggable.Token.t()

  require Logger

  @doc """
  Run a series of pluggable steps at runtime.

  The steps given here can be either a tuple, representing a module step
  and their options, or a simple function that receives a token and
  returns a token.

  If any of the steps halt, the remaining steps are not invoked. If the
  given token was already halted, none of the steps are invoked
  either.

  While `Pluggable.StepBuilder` works at compile-time, this is a
  straight-forward alternative that works at runtime.

  ## Examples

      Pluggable.run(token, [{My.Step, []}, &IO.inspect/1])

  ## Options

    * `:log_on_halt` - a log level to be used if a pipeline halts

  """
  @spec run(
          Pluggable.Token.t(),
          [{module, opts} | (Pluggable.Token.t() -> Pluggable.Token.t())],
          Keyword.t()
        ) ::
          Pluggable.Token.t()
  def run(token, steps, opts \\ []) do
    if Pluggable.Token.halted?(token),
      do: token,
      else: do_run(token, steps, Keyword.get(opts, :log_on_halt))
  end

  defp do_run(token, [{mod, opts} | steps], level) when is_atom(mod) do
    next_token = mod.call(token, mod.init(opts))

    if !Pluggable.Token.impl_for(next_token),
      do: raise("expected #{inspect(mod)} to return Pluggable.Token, got: #{inspect(next_token)}")

    if Pluggable.Token.halted?(next_token) do
      level && Logger.log(level, "Pluggable pipeline halted in #{inspect(mod)}.call/2")
      next_token
    else
      do_run(next_token, steps, level)
    end
  end

  defp do_run(token, [fun | steps], level) when is_function(fun, 1) do
    next_token = fun.(token)

    if !Pluggable.Token.impl_for(next_token),
      do: raise("expected #{inspect(fun)} to return Pluggable.Token, got: #{inspect(next_token)}")

    if Pluggable.Token.halted?(next_token) do
      level && Logger.log(level, "Pluggable pipeline halted in #{inspect(fun)}")
      next_token
    else
      do_run(next_token, steps, level)
    end
  end

  defp do_run(token, [], _level), do: token
end