lib/bureaucrat/helpers.ex

defmodule Bureaucrat.Helpers do
  alias Phoenix.Socket.{Broadcast, Message, Reply}
  alias Phoenix.Socket

  @doc """
  Adds a conn to the generated documentation.

  The name of the test currently being executed will be used as a description for the example.
  """
  defmacro doc(conn) do
    quote bind_quoted: [conn: conn] do
      doc(conn, [])
    end
  end

  @doc """
  Adds a Phoenix.Socket connection to the generated documentation.

  The name of the test currently being executed will be used as a description for the example.
  """
  defmacro doc_connect(
             handler,
             params,
             connect_info \\ quote(do: %{})
           ) do
    if endpoint = Module.get_attribute(__CALLER__.module, :endpoint) do
      quote do
        {status, socket} =
          unquote(Phoenix.ChannelTest).__connect__(unquote(endpoint), unquote(handler), unquote(params), unquote(connect_info))

        doc({status, socket, unquote(handler), unquote(params), unquote(connect_info)})
        {status, socket}
      end
    else
      raise "module attribute @endpoint not set for socket/2"
    end
  end

  @doc """
  Adds a Phoenix.Socket.Message to the generated documentation.

  The name of the test currently being executed will be used as a description for the example.
  """
  defmacro doc_push(socket, event) do
    quote bind_quoted: [socket: socket, event: event] do
      ref = make_ref()
      message = %Message{event: event, topic: socket.topic, ref: ref, payload: Phoenix.ChannelTest.__stringify__(%{})}
      doc(message, [])
      send(socket.channel_pid, message)
      ref
    end
  end

  defmacro doc_push(socket, event, payload) do
    quote bind_quoted: [socket: socket, event: event, payload: payload] do
      ref = make_ref()
      message = %Message{event: event, topic: socket.topic, ref: ref, payload: Phoenix.ChannelTest.__stringify__(payload)}
      doc(message, [])
      send(socket.channel_pid, message)
      ref
    end
  end

  defmacro doc_broadcast_from(socket, event, message) do
    quote bind_quoted: [socket: socket, event: event, message: message] do
      %{pubsub_server: pubsub_server, topic: topic, transport_pid: transport_pid} = socket
      broadcast = %Broadcast{topic: topic, event: event, payload: message}
      doc(broadcast, [])
      Phoenix.Channel.Server.broadcast_from(pubsub_server, transport_pid, topic, event, message)
    end
  end

  defmacro doc_broadcast_from!(socket, event, message) do
    quote bind_quoted: [socket: socket, event: event, message: message] do
      %{pubsub_server: pubsub_server, topic: topic, transport_pid: transport_pid} = socket
      broadcast = %Broadcast{topic: topic, event: event, payload: message}
      doc(broadcast, [])
      Phoenix.Channel.Server.broadcast_from!(pubsub_server, transport_pid, topic, event, message)
    end
  end

  @doc """
  Adds a conn to the generated documentation

  The description, and additional options can be passed in the second argument:

  ## Examples

      conn = conn()
        |> get("/api/v1/products")
        |> doc("List all products")

      conn = conn()
        |> get("/api/v1/products")
        |> doc(description: "List all products", operation_id: "list_products")
  """
  defmacro doc(conn, desc) when is_binary(desc) do
    quote bind_quoted: [conn: conn, desc: desc] do
      doc(conn, description: desc)
    end
  end

  defmacro doc(conn, opts) when is_list(opts) do
    # __CALLER__returns a `Macro.Env` struct
    #   -> https://hexdocs.pm/elixir/Macro.Env.html
    mod = __CALLER__.module
    fun = __CALLER__.function |> elem(0) |> to_string
    # full path as binary
    file = __CALLER__.file
    line = __CALLER__.line

    titles = Application.get_env(:bureaucrat, :titles)

    opts =
      opts
      |> Keyword.put_new(:description, format_test_name(fun))
      |> Keyword.put_new(:group_title, group_title_for(mod, titles))
      |> Keyword.put(:module, mod)
      |> Keyword.put(:file, file)
      |> Keyword.put(:line, line)

    quote bind_quoted: [conn: conn, opts: opts] do
      default_operation_id = get_default_operation_id(conn)

      opts =
        opts
        |> Keyword.put_new(:operation_id, default_operation_id)
        |> Keyword.update!(:description, &(&1 || default_description()))

      if Keyword.fetch!(opts, :description) != nil do
        Bureaucrat.Recorder.doc(conn, opts)
      else
        file = Keyword.fetch!(opts, :file)
        line = Keyword.fetch!(opts, :line)

        Mix.shell().info("""
        The request at #{file}:#{line} won't be recorded by bureaucrat because
        the description can't be determined and none is explicitly provided.
        To address this, you can pass the :description option to this macro.

        If this macro is invoked indirectly, via the request macros, such as
        get and post, you can switch to the _undocumented version to
        explicitly avoid generating the documentation.

        Alternatively, you can provide the description manually with something
        like doc(conn, description: "some description"), where conn is the result
        of the _undocumented macro.
        """)
      end

      conn
    end
  end

  def default_description do
    # The default description is taken from the test which invoked this code (if such test exists).
    # We'll first look into the call stack of this process. If we can't find a test function, we'll
    # look at the $callers process dictionary entry, which contains the pids of caller processes.
    # This allows us to find the owner test even if this function is running in a separate task
    # process. See https://hexdocs.pm/elixir/Task.html#module-ancestor-and-caller-tracking for
    # details.

    callers = Process.get(:"$callers") || []
    Enum.find_value([self() | callers], &test_description/1)
  end

  defp test_description(pid) do
    {:current_stacktrace, stacktrace} = Process.info(pid, :current_stacktrace)

    Enum.find_value(
      stacktrace,
      fn {module, function, _arity, opts} ->
        with "test " <> description <- to_string(function),
             true <- String.ends_with?(to_string(module), "Test"),
             true <- String.ends_with?(to_string(Keyword.get(opts, :file)), ".exs"),
             do: description,
             else: (_ -> nil)
      end
    )
  end

  def format_test_name("test " <> name), do: name
  def format_test_name(_other), do: nil

  def group_title_for(_mod, []), do: nil

  def group_title_for(mod, [{other, path} | paths]) do
    if String.replace_suffix(to_string(mod), "Test", "") == to_string(other) do
      path
    else
      group_title_for(mod, paths)
    end
  end

  def get_default_operation_id(%Plug.Conn{private: private}) do
    case private do
      %{phoenix_controller: elixir_controller, phoenix_action: action} ->
        "#{inspect(elixir_controller)}.#{action}"

      _ ->
        raise """
        Bureaucrat couldn't find a controller and/or action for this request.
        Possibly, the request is halted by a plug before it gets to the controller.
        Please use `get_undocumented` or `post_undocumented` (etc.) instead.
        """
    end
  end

  def get_default_operation_id(%Message{topic: topic, event: event}) do
    "#{topic}.#{event}"
  end

  def get_default_operation_id(%Broadcast{topic: topic, event: event}) do
    "#{topic}.#{event}"
  end

  def get_default_operation_id(%Reply{topic: topic}) do
    "#{topic}.reply"
  end

  def get_default_operation_id({_, _, %Socket{endpoint: endpoint}}) do
    "#{endpoint}.reply"
  end

  def get_default_operation_id({_, %Socket{endpoint: endpoint}, _, _, _}) do
    "#{endpoint}.connect"
  end

  @doc """
  Helper function for adding the phoenix_controller and phoenix_action keys to
  the private map of the request that's coming from the test modules.

  For example:

  test "all items - unauthenticated", %{conn: conn} do
    conn
    |> get(item_path(conn, :index))
    |> plug_doc(module: __MODULE__, action: :index)
    |> doc()
    |> assert_unauthenticated()
  end

  The request from this test will never touch the controller that's being tested,
  because it is being piped through a plug that authenticates the user and redirects
  to another page. In this scenario, we use the plug_doc function.
  """
  def plug_doc(conn, module: module, action: action) do
    controller_name = module |> to_string |> String.trim("Test")

    conn
    |> Plug.Conn.put_private(:phoenix_controller, controller_name)
    |> Plug.Conn.put_private(:phoenix_action, action)
  end
end