Skip to main content

lib/rpc_elixir/dispatcher.ex

defmodule RpcElixir.Dispatcher do
  @moduledoc """
  Core dispatch pipeline: lookup → middleware → validate input → invoke handler →
  validate output → serialize.

  Every transport (HTTP Plug, in-process caller) funnels through `dispatch/4`,
  which threads a `%RpcElixir.Resolution{}` through the procedure's middleware
  chain and the inner pipeline. The returned resolution carries the final
  outcome on its `:result` field, so middleware that wraps `dispatch/4` can
  observe and transform results uniformly.

  ## Typed handler errors

  When a handler returns `{:error, reason}`, the dispatcher promotes `reason`
  to a top-level `%RpcError{}`:

    - `reason` is an atom: `code = reason`, `message = Atom.to_string(reason)`,
      `details = nil`. HTTP status is derived by the transport from
      `RpcElixir.RpcError`'s framework status map (falling back to a generic
      status).
    - `reason` is a plain map (not a struct) with `:code` (atom): `code =
      reason.code`, `message = reason[:message] || Atom.to_string(code)`,
      `details = reason` minus `:code` and `:message` (or `nil` if empty).
      HTTP status is derived by the transport from `RpcElixir.RpcError`'s
      framework status map (falling back to a generic status).
    - `reason` is already an `%RpcError{}` with a non-nil `:source`: passed
      through unchanged. If `:source` is `nil`, it is stamped with
      `source: :domain` before being returned.
    - Anything else (structs, tuples, keyword lists, lists, …): wrapped as
      `%RpcError{code: :handler_error, details: %{kind: :error, reason:
      inspect(reason)}}` with status 500 (the default status for
      `:handler_error` in `RpcElixir.RpcError.framework_errors/0`). These are
      framework-level "handler returned something unexpected" cases.

  This contract matches the JS `Error` shape on the TypeScript client:
  `err.code`, `err.message`, and `err.details` are all populated for typed
  errors, and `err.message` shows up in stack traces and `console.error`.
  """

  alias RpcElixir.{Resolution, RpcError, Types}

  require Logger

  @typedoc "Final result populated on `Resolution.result` after dispatch."
  @type result :: {:ok, term()} | {:error, RpcError.t()}

  @doc """
  Dispatches a procedure call against `router`, returning the resolution with
  `:result` populated.

  - If the input resolution is already halted, it is returned as-is.
  - If the procedure path is unknown, the result is set to a
    `:procedure_not_found` `RpcError` without invoking middleware.
  - Otherwise, the procedure's middleware chain runs around the inner
    pipeline. Any middleware may halt the resolution to short-circuit.
  """
  @spec dispatch(module(), String.t(), map(), Resolution.t()) :: Resolution.t()
  def dispatch(_router, _path, _raw_input, %Resolution{state: :halted} = resolution) do
    resolution
  end

  def dispatch(router, path, raw_input, %Resolution{} = resolution) do
    case lookup(router, path) do
      {:ok, proc} ->
        resolution
        |> run_middleware(proc.middleware)
        |> apply_handler(proc, raw_input)

      {:error, %RpcError{} = err} ->
        %{resolution | result: {:error, err}}
    end
  end

  defp run_middleware(%Resolution{state: :halted} = res, _middleware), do: res
  defp run_middleware(res, []), do: res

  defp run_middleware(res, [{mod, opts} | rest]) do
    res
    |> mod.call(opts)
    |> run_middleware(rest)
  end

  defp apply_handler(%Resolution{state: :halted} = res, _proc, _raw_input), do: res

  defp apply_handler(%Resolution{result: existing} = res, proc, raw_input)
       when not is_nil(existing) do
    Logger.warning(
      "[rpc_elixir] middleware wrote :result on procedure #{inspect(proc.name)} without halting. " <>
        "It is being clobbered by the handler step. Use Resolution.halt/2 to short-circuit instead."
    )

    apply_handler(%{res | result: nil}, proc, raw_input)
  end

  defp apply_handler(%Resolution{} = res, proc, raw_input) do
    result =
      with {:ok, validated_input} <- validate_input(proc, raw_input),
           {:ok, handler_output} <- invoke(proc, validated_input, res.ctx) do
        validate_and_serialize_output(proc, handler_output)
      end

    %{res | result: result}
  end

  defp lookup(router, path) do
    case Map.fetch(router.__procedures_index__(), path) do
      {:ok, _proc} = ok ->
        ok

      :error ->
        {:error,
         RpcError.framework(
           :procedure_not_found,
           "no procedure registered for path #{inspect(path)}"
         )}
    end
  end

  defp validate_input(proc, raw_input) do
    case Types.validate(proc.input, raw_input) do
      {:ok, _} = ok ->
        ok

      {:error, field_errors} ->
        {:error,
         RpcError.framework(:input_validation_failed, "input validation failed", field_errors)}
    end
  end

  defp invoke(proc, validated_input, ctx) do
    case apply(proc.handler_mod, proc.handler_fun, [validated_input, ctx]) do
      {:ok, _} = ok ->
        ok

      {:error, reason} ->
        {:error, build_typed_error(reason)}

      other ->
        Logger.error(
          "[rpc_elixir] handler #{inspect(proc.handler_mod)}.#{proc.handler_fun} returned unexpected value: #{inspect(other)}"
        )

        {:error, handler_error(:unexpected_return, inspect(other))}
    end
  rescue
    e ->
      Logger.error(
        "[rpc_elixir] handler #{inspect(proc.handler_mod)}.#{proc.handler_fun} raised:\n" <>
          Exception.format(:error, e, __STACKTRACE__)
      )

      {:error, handler_error(:exception, Exception.message(e))}
  end

  @handler_error_messages %{
    unexpected_return: "handler returned an unexpected value",
    exception: "handler raised an exception"
  }

  @handler_error_detail_keys %{unexpected_return: :value, exception: :message}

  defp handler_error(kind, detail) do
    details =
      if expose_error_details?() do
        %{:kind => kind, @handler_error_detail_keys[kind] => detail}
      else
        %{kind: kind}
      end

    RpcError.framework(:handler_error, @handler_error_messages[kind], details)
  end

  defp expose_error_details? do
    Application.get_env(:elixir_ts_rpc, :expose_error_details, false)
  end

  defp build_typed_error(%RpcError{source: nil} = err), do: %{err | source: :domain}
  defp build_typed_error(%RpcError{} = err), do: err

  defp build_typed_error(code) when is_atom(code) and not is_nil(code) do
    %RpcError{code: code, message: Atom.to_string(code), source: :domain}
  end

  defp build_typed_error(%{code: code} = map) when is_atom(code) and not is_struct(map) do
    rest = Map.drop(map, [:code, :message])
    details = if map_size(rest) == 0, do: nil, else: rest
    message = Map.get(map, :message) || Atom.to_string(code)
    %RpcError{code: code, message: message, details: details, source: :domain}
  end

  defp build_typed_error(other) do
    RpcError.framework(:handler_error, "handler returned an error", %{
      kind: :error,
      reason: inspect(other)
    })
  end

  defp validate_and_serialize_output(proc, output) do
    case Types.validate(proc.output, output) do
      {:ok, coerced} ->
        {:ok, Types.serialize(proc.output, coerced)}

      {:error, field_errors} ->
        {:error,
         RpcError.framework(
           :output_validation_failed,
           "output validation failed — this is a server bug",
           field_errors
         )}
    end
  end
end