Skip to main content

lib/rpc_elixir/handler.ex

defmodule RpcElixir.Handler do
  @moduledoc """
  Captures `@spec` and `@type` ASTs at handler-compile time and exposes them
  via `__rpc_specs__/0` / `__rpc_types__/0` accessors.

  ## Why this exists

  `RpcElixir.Router` validates handler signatures inside `__before_compile__`.
  By default it reads them from the handler's BEAM file via
  `Code.Typespec.fetch_specs/1`, which only works after the BEAM is on disk.
  Inside a single Mix project, the parallel compiler may run the router's
  compile-time hook before in-progress handler BEAMs are flushed, producing
  spurious "no @spec" errors.

  When a handler does `use RpcElixir.Handler`, this macro captures the spec
  ASTs into a generated function. The router (via `RpcElixir.Types.FromSpec`)
  prefers that accessor when it exists, and the resulting function-call edge
  forces the parallel compiler to fully compile the handler module before
  using it — without requiring the BEAM to be on disk.

  Without `use RpcElixir.Handler`, the framework still works but requires the
  handler to live in a separate Mix `path:` dep so its BEAM is on disk first.

  ## Input keys are atoms

  The `input` argument received by every handler function has **atom keys**
  (e.g. `%{id: "abc"}`), never string keys. Pattern-match and access
  accordingly:

      def get(%{id: id}, _ctx), do: ...   # correct
      def get(%{"id" => id}, _ctx), do: ... # wrong — key will be absent

  ## Usage

      defmodule MyApp.Handlers.Users do
        use RpcElixir.Handler

        @spec list(input :: %{}, ctx :: map()) :: {:ok, %{users: [%{id: String.t()}]}}
        def list(_input, _ctx), do: {:ok, %{users: []}}
      end
  """

  defmacro __using__(_opts) do
    quote do
      @before_compile RpcElixir.Handler
    end
  end

  defmacro __before_compile__(env) do
    public_defs = MapSet.new(Module.definitions_in(env.module, :def))

    specs =
      env.module
      |> collect_specs()
      |> Map.filter(fn {{name, arity}, _ast} ->
        arity in [1, 2] and MapSet.member?(public_defs, {name, arity})
      end)

    types = collect_types(env.module)

    quote do
      @doc false
      def __rpc_specs__, do: unquote(Macro.escape(specs))

      @doc false
      def __rpc_types__, do: unquote(Macro.escape(types))
    end
  end

  defp collect_specs(module) do
    (Module.get_attribute(module, :spec) || [])
    |> Enum.flat_map(&spec_entry/1)
    |> Map.new()
  end

  # @spec foo(arg1, arg2) :: return
  defp spec_entry({:spec, {:"::", meta, [{name, m2, args}, return]}, _})
       when is_atom(name) and is_list(args) do
    stripped = {:"::", meta, [{name, m2, Enum.map(args, &strip_named_arg/1)}, return]}
    [{{name, length(args)}, stripped}]
  end

  # @spec foo(arg1, arg2) :: return when v1: t, ...
  defp spec_entry(
         {:spec, {:when, when_meta, [{:"::", spec_meta, [{name, m2, args}, return]}, bindings]},
          _}
       )
       when is_atom(name) and is_list(args) do
    stripped =
      {:when, when_meta,
       [
         {:"::", spec_meta, [{name, m2, Enum.map(args, &strip_named_arg/1)}, return]},
         bindings
       ]}

    [{{name, length(args)}, stripped}]
  end

  defp spec_entry(_), do: []

  # `Code.Typespec.spec_to_quoted/2` discards `name ::` arg labels, but the raw
  # AST in `Module.get_attribute(:spec)` keeps them. Strip them here so both
  # paths through `FromSpec` see the same shape.
  defp strip_named_arg({:"::", _, [{name, _, ctx}, type]}) when is_atom(name) and is_atom(ctx),
    do: type

  defp strip_named_arg(other), do: other

  defp collect_types(module) do
    for kind <- [:type, :typep, :opaque],
        entry <- Module.get_attribute(module, kind) || [],
        result <- type_entry(kind, entry),
        into: %{},
        do: result
  end

  # @type name :: body — zero-arity, var ctx is an atom (e.g. nil or Elixir env)
  defp type_entry(kind, {kind, {:"::", _, [{name, _, ctx}, body]}, _})
       when is_atom(name) and is_atom(ctx) do
    [{{name, 0}, {[], body}}]
  end

  # @type name(v1, v2) :: body
  defp type_entry(kind, {kind, {:"::", _, [{name, _, var_asts}, body]}, _})
       when is_atom(name) and is_list(var_asts) do
    var_names = Enum.map(var_asts, fn {v, _, _} -> v end)
    [{{name, length(var_names)}, {var_names, body}}]
  end

  defp type_entry(_kind, _), do: []
end