lib/flame_on/component/capture_schema.ex

defmodule FlameOn.Component.CaptureSchema do
  use Ecto.Schema
  import Ecto.Changeset
  alias Ecto.Changeset

  require Logger

  schema "capture" do
    field :module, :string
    field :function, :string
    field :arity, :integer
    field :timeout, :integer
  end

  @default_cowboy_attrs %{module: "cowboy_handler", function: "execute", arity: 2, timeout: 15000}
  @default_bandit_attrs %{module: "Bandit.Pipeline", function: "run", arity: 4, timeout: 15000}

  def changeset(node, attrs \\ nil) do
    attrs = attrs || default_attrs(node)

    %__MODULE__{}
    |> cast(attrs, [:module, :function, :arity, :timeout])
    |> validate_required([:module, :function, :arity, :timeout])
    |> validate_module(node)
    |> validate_function_arity(node)
  end

  defp validate_module(%Changeset{valid?: false} = changeset, _node), do: changeset

  defp validate_module(changeset, node) do
    module =
      changeset
      |> get_field(:module)
      |> maybe_prepend_elixir()
      |> rpc_to_existing_atom(node)

    if is_nil(module) or !rpc_code_ensure_loaded?(module, node) do
      add_error(changeset, :module, "Module not found")
    else
      changeset
    end
  end

  @doc """
  Since we support both Elixir and Erlang modules, and are taking a string argument, modules have to be
  converted to the Erlang notation before being passed to various checks and to :meck. This means an Elixir module like
  Phoenix.LiveView would have to be converted to the atom :"Elixir.Phoenix.LiveView", but cowboy_handler remains
  :cowboy_handler. If they already prepended `Elixir.` then we pass the module as is.
  """
  def maybe_prepend_elixir(""), do: ""
  def maybe_prepend_elixir("Elixir." <> _ = module_str), do: module_str

  def maybe_prepend_elixir(module_str) do
    first = String.at(module_str, 0)

    if String.upcase(first) == first do
      "Elixir." <> module_str
    else
      module_str
    end
  end

  defp validate_function_arity(%Changeset{valid?: false} = changeset, _node), do: changeset

  defp validate_function_arity(changeset, node) do
    module = changeset |> get_field(:module) |> maybe_prepend_elixir() |> String.to_existing_atom()

    function_str = get_field(changeset, :function)
    arity = get_field(changeset, :arity)

    function = rpc_to_existing_atom(function_str, node)

    if is_nil(function) or not rpc_function_exported?(module, function, arity, node) do
      add_error(changeset, :function, "No #{function_str}/#{arity} function on #{module}")
    else
      changeset
    end
  end

  defp rpc_to_existing_atom(string, node) do
    case :rpc.call(node, String, :to_existing_atom, [string]) do
      atom when is_atom(atom) -> atom
      {:badrpc, {:EXIT, {:badarg, _}}} -> nil
    end
  end

  defp rpc_code_ensure_loaded?(module, node) do
    :rpc.call(node, Code, :ensure_loaded?, [module])
  end

  defp rpc_function_exported?(module, function, arity, node) do
    :rpc.call(node, Kernel, :function_exported?, [module, function, arity])
  end

  defp default_attrs(node) do
    cond do
      rpc_function_exported?(@default_bandit_attrs.module, @default_bandit_attrs.function, @default_bandit_attrs.arity, node) ->
        @default_bandit_attrs

      rpc_function_exported?(@default_cowboy_attrs.module, @default_cowboy_attrs.function, @default_cowboy_attrs.arity, node) ->
        @default_cowboy_attrs

      true ->
        Logger.info("FlameOn did not detect Cowboy or Bandit modules")
        %{}
    end
  end
end