lib/binance_api/http.ex

defmodule BinanceApi.HTTP do
  require Logger

  alias BinanceApi.{Config, HTTP.UrlGenerator}

  @opts_definition [
    secret_key: [type: :string, required: true],
    api_key: [type: :string, required: true],
    secured?: [type: :boolean, default: false],
    base_futures_url: [type: :string, default: Config.base_futures_url()],
    base_url: [type: :string, default: Config.base_url()],
    secure_receive_window: [type: :non_neg_integer, default: Config.secure_receive_window()],
    pool_timeout: [type: :non_neg_integer, default: Config.request_pool_timeout()],
    receive_timeout: [type: :non_neg_integer, default: Config.request_receive_timeout()]
  ]

  @moduledoc """
  This module constructs a request for binance using authentication information
  that is set in by the `opts` param

  By default the opts will use the `api_key` and `secret_key` set in the config

  ### Options

  #{NimbleOptions.docs(@opts_definition)}
  """

  @type opts :: [
    secured?: boolean,
    secret_key: String.t,
    api_key: String.t,
    base_futures_url: String.t,
    base_url: String.t,
    secure_receive_window: non_neg_integer,
    pool_timeout: non_neg_integer,
    receive_timeout: non_neg_integer
  ]

  @type error :: {:error, %{code: atom, message: String.t}}
  @type res :: {:ok, map | list} | error
  @type res_multi :: {:ok, list} | error
  @type res_single :: {:ok, map} | error

  @authorization_failure_error_codes [403]
  @not_found_failure_error_codes [404]

  @spec opts_definitions() :: Keyword.t
  def opts_definitions, do: @opts_definition

  @spec build_futures_url(String.t) :: String.t
  def build_futures_url(path), do: Path.join("/fapi/v1", path)

  @spec build_v1_url(String.t) :: String.t
  def build_v1_url(path), do: Path.join("/sapi/v1", path)

  @spec build_v3_url(String.t) :: String.t
  def build_v3_url(path), do: Path.join("/api/v3", path)

  @spec futures_get(String.t, Keyword.t) :: res
  @spec futures_get(String.t, nil | map, Keyword.t) :: res
  def futures_get(url, params \\ nil, opts) do
    url |> build_futures_url |> get(params, make_futures_request(opts))
  end

  @spec futures_delete(String.t, Keyword.t) :: res
  @spec futures_delete(String.t, nil | map, Keyword.t) :: res
  def futures_delete(url, params \\ nil, opts) do
    url |> build_futures_url |> delete(params, make_futures_request(opts))
  end

  @spec futures_post(String.t, nil | map, Keyword.t) :: res
  def futures_post(url, body, opts) do
    url |> build_futures_url |> post(body, make_futures_request(opts))
  end

  defp make_futures_request(opts) do
    opts = opts |> add_default_opts |> NimbleOptions.validate!(@opts_definition)

    opts
      |> Keyword.put(:base_url, opts[:base_futures_url])
      |> Keyword.delete(:base_futures_url)
  end

  @spec get(String.t, Keyword.t) :: res
  @spec get(String.t, nil | map, Keyword.t) :: res
  def get(url, params \\ nil, opts) do
    request(:get, url, params, opts)
  end

  @spec delete(String.t, Keyword.t) :: res
  @spec delete(String.t, nil | map, Keyword.t) :: res
  def delete(url, params \\ nil, opts) do
    request(:delete, url, params, opts)
  end

  @spec post(String.t, nil | map, Keyword.t) :: res
  def post(url, body, opts) do
    request(:post, url, body, opts)
  end

  defp request(method, url, body, opts) do
    opts = opts |> add_default_opts |> NimbleOptions.validate!(@opts_definition)
    url = UrlGenerator.build(url, body, opts)

    Logger.debug("BinanceApi making request to #{url}#{if body, do: "\nBody: #{inspect body}"}")

    res = method
      |> Finch.build(url, build_headers(opts[:api_key]), nil)
      |> Finch.request(BinanceApi.Finch, Keyword.take(opts, [:pool_timeout, :receive_timeout]))

    with {:ok, res} <- res do
      handle_response(res)
    end
  end

  defp add_default_opts(opts) do
    opts
      |> Keyword.put_new(:api_key, BinanceApi.Config.api_key())
      |> Keyword.put_new(:secret_key, BinanceApi.Config.secret_key())
  end

  defp build_headers(api_key) do
    [
      {"X-MBX-APIKEY", api_key}
    ]
  end

  defp handle_response(%Finch.Response{status: 200, body: body}) do
    with {:ok, body} <- Jason.decode(body) do
      {:ok, ProperCase.to_snake_case(body)}
    end
  end

  defp handle_response(%Finch.Response{
    status: status_code,
    body: body
  } = request) when status_code in @not_found_failure_error_codes do
    {:error, %{code: :not_found, message: body, details: request}}
  end

  defp handle_response(%Finch.Response{
    status: status_code,
    body: body
  } = request) when status_code in @authorization_failure_error_codes do
    {:error, %{code: :unauthorized, message: body, details: request}}
  end

  defp handle_response(%Finch.Response{} = request) do
    {:error, %{code: :internal_server_error, message: "unhandled error code", details: request}}
  end
end