lib/web/controller/plug.ex

# Copyright(c) 2015-2023 ACCESS CO., LTD. All rights reserved.

use Croma

defmodule Antikythera.Controller.Plug do
  @moduledoc """
  Macro definition for plug DSL.

  "Plug" is a specification of composable modules for web apps.
  For example, you can define your authentication logic as a plug and apply it to all your controller actions.

  ## Usage

  To use plug you must first add `use Antikythera.Controller` in your controller module.
  Then you can invoke `plug/3` macro as follows.

      plug Antikythera.Plug.BasicAuthentication, :check_with_config, []

  The arguments are

  1. module
  2. function name
  3. options to be passed to plug function
  4. (optional) options for enabling plug

  In this case `Antikythera.Plug.BasicAuthentication.check_with_config/2` is invoked just before execution of actions
  defined in this controller module.
  If you want to skip running plug for some actions in a controller module, you can use `:except` or `:only` option.

      plug YourGear.SomePlug1, :do_something, [], [except: [:action_x, :action_y]]
      plug YourGear.SomePlug2, :do_something, [], [only: [:action_x]]
  """

  defmacro __using__(_opts) do
    quote do
      import Antikythera.Controller.Plug
      Module.register_attribute(__MODULE__, :plugs, accumulate: true)
      @before_compile Antikythera.Controller.Plug
    end
  end

  defmacro __before_compile__(_env) do
    quote do
      @plugs_reversed Enum.reverse(@plugs)

      def __action__(conn, action) do
        # Be careful when you edit the body of this function: changes won't be applied until all gears are re-compiled.
        Antikythera.Controller.Plug._run_action(conn, action, __MODULE__, @plugs_reversed)
      end
    end
  end

  defmacro plug(mod, func, plug_arg, opts \\ []) when is_atom(func) do
    plug_impl(mod, func, plug_arg, opts)
  end

  defp plug_impl(module, func, plug_arg, opts) do
    except = Keyword.get(opts, :except, [])
    only = Keyword.get(opts, :only, [])
    if !Enum.all?(except, &is_atom/1), do: raise(":except must be a list of action names (atoms)")
    if !Enum.all?(only, &is_atom/1), do: raise(":only must be a list of action names (atoms)")

    if !Enum.empty?(except) and !Enum.empty?(only),
      do: raise("either :except or :only must be empty")

    quote bind_quoted: [
            module: module,
            func: func,
            plug_arg: plug_arg,
            except: except,
            only: only
          ] do
      @plugs {module, func, plug_arg, except, only}
    end
  end

  @doc false
  def _run_action(conn, action, controller, plugs_reversed) do
    AntikytheraCore.GearLog.ContextHelper.set(conn)

    plugs =
      Enum.filter(plugs_reversed, fn
        {_, _, _, [], []} -> true
        {_, _, _, except_actions, []} -> action not in except_actions
        {_, _, _, [], only_actions} -> action in only_actions
      end)

    run_action_with_plugs(conn, controller, action, plugs)
  end

  defp run_action_with_plugs(conn, controller, action, []) do
    apply(controller, action, [conn])
  end

  defp run_action_with_plugs(conn, controller, action, [{mod, fun, arg, _except, _only} | plugs]) do
    %Antikythera.Conn{status: status} = conn2 = apply(mod, fun, [conn, arg])

    case status do
      nil -> run_action_with_plugs(conn2, controller, action, plugs)
      _ -> conn2
    end
  end
end