lib/json_rpc.ex

defmodule JsonRPC do
  @moduledoc """
  A module to create JSON-RPC clients and handle JSON-RPC requests.

  For more information to JSON-RPC 2.0 see the
  [specification](https://www.jsonrpc.org/specification).

  `JsonRPC` comes without any code to sending the generated JSON-RPCs. For the
  transport the behaviour `JsonRPC.Transport` has to be used.

  `JsonRPC` uses `jason` per default. To customize the JSON library, including the
  following in your config:
  ```elixir
  config :json_rpc, parser: :poison
  ```

  ## Client

  A client can be written by using `JsonRPC`.

  ```elixir
  defmodule Example.Client do
    use JsonRPC, transport: Example.Transport

    rpc add(a, b)

    rpc msg(text), notification: true

    rpc message(text), notification: true, delegate: "msg"

    rpc params(map), params: :by_name
  end
  ```
  The module `Example.Client` contains now the functions `add/2`, `msg/1`,
  `message/1` and `params/1`. When one of this function will be called a
  JSON-RPC will be generated and `Example.Transport.send_rpc/2` will be called.
  The first argument of `Transport.send_rpc/2` is the encoded JSON-RPC and the
  second argument are the options given to `JsonRPC`.

  See `Transport.send_rpc` for more information.

  See `rpc/2` for more information about the options.

  ## Server

  A server has to use the function `handle_request/2`.
  """

  alias JsonRPC.Handler
  alias JsonRPC.HandleRequestError
  alias JsonRPC.Request

  @type json :: binary() | map()

  defmacro __using__(opts) do
    quote do
      use JsonRPC.ID, impl: Keyword.get(unquote(opts), :id, JsonRPC.UID)

      import JsonRPC

      Module.put_attribute(__MODULE__, :json_rpc_opts, defaults(unquote(opts)))
    end
  end

  @doc """
  Defines a RPC with the given name and arguments.

  Options:
  - `:notification` - a boolean to mark a RPC as a notification, defaults to
    `false`
  - `:params` - treat arguments either as `:by_position` or `:by_name`, defaults
    to `:by_position`. The RPC expected a keyword list or a map when
    `params: :by_name` is set
  - `:delegate` - a string that overrides the RPC name
  """
  defmacro rpc(expr, opts \\ []) do
    rpc(call_type(opts), params_type(opts), expr, delegate(opts))
  end

  @doc """
  Handles the given `json` response.

  The response must contain an `id` from a previously sent request. This
  function is usually used in an asynchronous `JsonRPC.Transport` implementation.
  """
  @spec handle_response(json()) :: :ok | {:error, :no_request} | {:error, term()}
  def handle_response(json) when is_binary(json) do
    Handler.handle_response({:ok, json})
  end

  @doc """
  Handles the given `json` request and executes the corresponding function on
  the given `module`.

  The function returns the result of the called function as an JSON-RPC
  response.

  If the given request encoded JSON, the result is also encoded JSON, and for a
  decoded request, a decode response is returned.

  For a notification (a request without id) the function returns `:notification`.

  `handle_request/2` catches exceptions raised by the called function and raises
  a `JsonRPC.HandleRequestError`. This error could be handled with
  `handle_error/2`.

  ## Examples

      iex> defmodule Math do
      ...>   def add(a, b), do: a + b
      ...> end
      iex> request = %{id: "id-1", jsonrpc: "2.0", method: "add", params: [1, 2]}
      iex> JsonRPC.handle_request(request, Math)
      %{id: "id-1", jsonrpc: "2.0", result: 3}
      iex> JsonRPC.handle_request(Jason.encode!(request), Math)
      ~s|{"id":"id-1","jsonrpc":"2.0","result":3}|
      iex> request = %{id: "id-2", jsonrpc: "2.0", method: "unknown", params: [1, 2]}
      iex> JsonRPC.handle_request(request, Math)
      %{
        id: "id-2",
        error: %{
          code: -32601,
          data: %{method: "unknown"},
          message: "Method not found"
        },
        jsonrpc: "2.0"
      }
  """
  @spec handle_request(json(), module()) :: :notification | json()
  def handle_request(json, module) do
    Handler.handle_request(json, module)
  end

  @doc """
  Handles the given `HandleRequestError`.

  This function generates a JSON-RPC error response for the given `error`.
  The optional `opts` can overwrite the values for `:code` and `:message`.

  ## Examples

      iex> defmodule Mod do
      ...>   def carp, do: raise RuntimeError, "no!"
      ...> end
      iex> request = %{id: 3, jsonrpc: "2.0", method: "carp", params: []}
      iex> try do
      ...>   JsonRPC.handle_request(request, Mod)
      ...> rescue
      ...>   error in JsonRPC.HandleRequestError ->
      ...>     JsonRPC.handle_error(error)
      ...> end
      %{
        id: 3,
        error: %{code: -32000, message: "** (RuntimeError) no!"},
        jsonrpc: "2.0"
      }
  """
  @spec handle_error(%HandleRequestError{}, keyword()) :: json()
  def handle_error(%HandleRequestError{} = error, opts \\ []) when is_list(opts) do
    Handler.handle_error(error, opts)
  end

  @doc false
  def check_by_name(_params, :by_position), do: :ok

  def check_by_name([map], :by_name) when is_map(map), do: :ok

  def check_by_name(_params, :by_name), do: {:error, :invalid_params}

  @doc false
  def rpc(:call, :by_position, {name, meta, params}, delegate) do
    quote do
      def unquote({name, meta, params}) do
        Handler.remote(
          Request.cast!(
            id: __json_rpc_id__(),
            method: unquote(delegate || name),
            params: unquote(params)
          ),
          @json_rpc_opts
        )
      end
    end
  end

  def rpc(:call, :by_name, {name, _meta, [param]} = expr, delegate) do
    quote do
      def unquote(expr) when is_map(unquote(param)) or is_list(unquote(param)) do
        params =
          case is_list(unquote(param)) do
            false ->
              unquote(param)

            true ->
              case Keyword.keyword?(unquote(param)) do
                true -> Enum.into(unquote(param), %{})
                false -> raise ArgumentError, "rpc expects a keyword list or a map"
              end
          end

        Handler.remote(
          Request.cast!(
            id: __json_rpc_id__(),
            method: unquote(delegate || name),
            params: params
          ),
          @json_rpc_opts
        )
      end
    end
  end

  def rpc(:notification, :by_position, {name, _meta, params} = expr, delegate) do
    quote do
      def unquote(expr) do
        Handler.remote(
          Request.cast!(
            method: unquote(delegate || name),
            params: unquote(params)
          ),
          @json_rpc_opts
        )
      end
    end
  end

  def rpc(:notification, :by_name, {name, _meta, [param]} = expr, delegate) do
    quote do
      def unquote(expr) when is_map(unquote(param)) do
        Handler.remote(
          Request.cast!(
            method: unquote(delegate || name),
            params: unquote(param)
          ),
          @json_rpc_opts
        )
      end
    end
  end

  @doc false
  def call_type(opts) do
    case Keyword.get(opts, :notification, false) do
      false -> :call
      true -> :notification
    end
  end

  @doc false
  def params_type(opts), do: Keyword.get(opts, :params, :by_position)

  @doc false
  def delegate(opts), do: Keyword.get(opts, :delegate)

  @doc false
  def defaults(opts) do
    Keyword.put_new(opts, :parser, JsonRPC.JasonParser)
  end
end