Skip to main content

lib/ccxt/http_executor.ex

defmodule Ccxt.HttpExecutor do
  @moduledoc """
  Minimal public HTTP executor for generated raw endpoint metadata.
  """

  alias Ccxt.RawEndpoint

  @type transport ::
          (method :: String.t(),
           url :: String.t(),
           headers :: [{String.t(), String.t()}],
           body :: binary() ->
             {:ok, non_neg_integer(), [{String.t(), String.t()}], binary()} | {:error, term()})

  @spec fetch(RawEndpoint.t(), map() | keyword(), keyword()) :: {:ok, term()} | {:error, term()}
  def fetch(%RawEndpoint{} = endpoint, params \\ %{}, opts \\ []) do
    transport = Keyword.get(opts, :transport, &default_transport/4)

    with :ok <- ensure_supported_method(endpoint),
         {:ok, url, headers, request_body} <- request(endpoint, params, opts),
         {:ok, status, _headers, body} <-
           transport.(endpoint.http_method, url, headers, request_body) do
      decode_response(status, body)
    end
  end

  @spec url(RawEndpoint.t(), map() | keyword(), keyword()) ::
          {:ok, String.t()} | {:error, term()}
  def url(%RawEndpoint{} = endpoint, params \\ %{}, opts \\ []) do
    with {:ok, base_url} <- base_url(endpoint, opts) do
      {path, query_params} = interpolate_path(endpoint.path, params)
      query = encode_query(query_params)
      url = base_url <> "/" <> path

      if query == "" do
        {:ok, url}
      else
        {:ok, url <> "?" <> query}
      end
    end
  end

  @spec request(RawEndpoint.t(), map() | keyword(), keyword()) ::
          {:ok, String.t(), [{String.t(), String.t()}], binary()} | {:error, term()}
  def request(%RawEndpoint{} = endpoint, params \\ %{}, opts \\ []) do
    cond do
      api_key_only_endpoint?(endpoint) ->
        api_key_only_request(endpoint, params, opts)

      signed_endpoint?(endpoint) ->
        signed_request(endpoint, params, opts)

      true ->
        with {:ok, built_url} <- url(endpoint, params, opts) do
          {:ok, built_url, [], ""}
        end
    end
  end

  defp base_url(endpoint, opts)

  defp base_url(%RawEndpoint{exchange: "binance", api_path: [api | _]}, opts) do
    binance_base_url(api, binance_env(opts))
  end

  defp base_url(%RawEndpoint{exchange: "bybit", api_path: [api | _]}, _opts)
       when api in ["public", "spot", "futures", "v2"] do
    {:ok, "https://api.bybit.com"}
  end

  defp base_url(%RawEndpoint{exchange: "digifinex"}, _opts) do
    {:ok, "https://openapi.digifinex.com"}
  end

  defp base_url(%RawEndpoint{exchange: "weex", api_path: ["public" | _]}, _opts) do
    {:ok, "https://api-spot.weex.com"}
  end

  defp base_url(%RawEndpoint{exchange: "whitebit", api_path: [version, "public" | _]}, _opts) do
    case version do
      "v1" -> {:ok, "https://whitebit.com/api/v1/public"}
      "v2" -> {:ok, "https://whitebit.com/api/v2/public"}
      "v4" -> {:ok, "https://whitebit.com/api/v4/public"}
      _ -> {:error, {:unsupported_base_url, "whitebit", version}}
    end
  end

  defp base_url(%RawEndpoint{exchange: exchange, api_path: api_path}, _opts) do
    {:error, {:unsupported_base_url, exchange, api_path}}
  end

  defp binance_base_url(api, "prod") do
    case api do
      "sapi" -> {:ok, "https://api.binance.com/sapi/v1"}
      "sapiV2" -> {:ok, "https://api.binance.com/sapi/v2"}
      "sapiV3" -> {:ok, "https://api.binance.com/sapi/v3"}
      "sapiV4" -> {:ok, "https://api.binance.com/sapi/v4"}
      "dapiPublic" -> {:ok, "https://dapi.binance.com/dapi/v1"}
      "dapiPrivate" -> {:ok, "https://dapi.binance.com/dapi/v1"}
      "dapiPrivateV2" -> {:ok, "https://dapi.binance.com/dapi/v2"}
      "dapiData" -> {:ok, "https://dapi.binance.com/futures/data"}
      "eapiPublic" -> {:ok, "https://eapi.binance.com/eapi/v1"}
      "eapiPrivate" -> {:ok, "https://eapi.binance.com/eapi/v1"}
      "fapiData" -> {:ok, "https://fapi.binance.com/futures/data"}
      "fapiPublic" -> {:ok, "https://fapi.binance.com/fapi/v1"}
      "fapiPublicV2" -> {:ok, "https://fapi.binance.com/fapi/v2"}
      "fapiPublicV3" -> {:ok, "https://fapi.binance.com/fapi/v3"}
      "fapiPrivate" -> {:ok, "https://fapi.binance.com/fapi/v1"}
      "fapiPrivateV2" -> {:ok, "https://fapi.binance.com/fapi/v2"}
      "fapiPrivateV3" -> {:ok, "https://fapi.binance.com/fapi/v3"}
      "private" -> {:ok, "https://api.binance.com/api/v3"}
      "public" -> {:ok, "https://api.binance.com/api/v3"}
      "v1" -> {:ok, "https://api.binance.com/api/v1"}
      "papi" -> {:ok, "https://papi.binance.com/papi/v1"}
      "papiV2" -> {:ok, "https://papi.binance.com/papi/v2"}
      _ -> {:error, {:unsupported_base_url, "binance", api}}
    end
  end

  defp binance_base_url(api, "testnet") do
    case api do
      "dapiPublic" -> {:ok, "https://testnet.binancefuture.com/dapi/v1"}
      "dapiPrivate" -> {:ok, "https://testnet.binancefuture.com/dapi/v1"}
      "dapiPrivateV2" -> {:ok, "https://testnet.binancefuture.com/dapi/v2"}
      "fapiPublic" -> {:ok, "https://testnet.binancefuture.com/fapi/v1"}
      "fapiPublicV2" -> {:ok, "https://testnet.binancefuture.com/fapi/v2"}
      "fapiPublicV3" -> {:ok, "https://testnet.binancefuture.com/fapi/v3"}
      "fapiPrivate" -> {:ok, "https://testnet.binancefuture.com/fapi/v1"}
      "fapiPrivateV2" -> {:ok, "https://testnet.binancefuture.com/fapi/v2"}
      "fapiPrivateV3" -> {:ok, "https://testnet.binancefuture.com/fapi/v3"}
      "private" -> {:ok, "https://testnet.binance.vision/api/v3"}
      "public" -> {:ok, "https://testnet.binance.vision/api/v3"}
      "v1" -> {:ok, "https://testnet.binance.vision/api/v1"}
      _ -> {:error, {:unsupported_binance_env_api, "testnet", api}}
    end
  end

  defp binance_base_url(api, "demo") do
    case api do
      "dapiPublic" -> {:ok, "https://demo-dapi.binance.com/dapi/v1"}
      "dapiPrivate" -> {:ok, "https://demo-dapi.binance.com/dapi/v1"}
      "dapiPrivateV2" -> {:ok, "https://demo-dapi.binance.com/dapi/v2"}
      "fapiPublic" -> {:ok, "https://demo-fapi.binance.com/fapi/v1"}
      "fapiPublicV2" -> {:ok, "https://demo-fapi.binance.com/fapi/v2"}
      "fapiPublicV3" -> {:ok, "https://demo-fapi.binance.com/fapi/v3"}
      "fapiPrivate" -> {:ok, "https://demo-fapi.binance.com/fapi/v1"}
      "fapiPrivateV2" -> {:ok, "https://demo-fapi.binance.com/fapi/v2"}
      "fapiPrivateV3" -> {:ok, "https://demo-fapi.binance.com/fapi/v3"}
      "private" -> {:ok, "https://demo-api.binance.com/api/v3"}
      "public" -> {:ok, "https://demo-api.binance.com/api/v3"}
      "v1" -> {:ok, "https://demo-api.binance.com/api/v1"}
      _ -> {:error, {:unsupported_binance_env_api, "demo", api}}
    end
  end

  defp binance_env(opts) do
    opts
    |> Keyword.get(:binance_env, System.get_env("BINANCE_ENV", "prod"))
    |> to_string()
    |> String.downcase()
    |> case do
      value when value in ["", "prod", "production", "mainnet"] -> "prod"
      value when value in ["test", "testnet", "sandbox"] -> "testnet"
      "demo" -> "demo"
      value -> value
    end
  end

  defp encode_query(params) do
    params
    |> Enum.map(fn {key, value} -> {to_string(key), value} end)
    |> URI.encode_query()
  end

  defp encode_query_with_array_repeat(params) do
    params
    |> Enum.flat_map(fn {key, value} ->
      key = to_string(key)

      if is_list(value) do
        Enum.map(value, &{key, &1})
      else
        [{key, value}]
      end
    end)
    |> URI.encode_query()
  end

  defp raw_encode_query(params) do
    params
    |> Enum.map(fn {key, value} -> to_string(key) <> "=" <> to_string(value) end)
    |> Enum.join("&")
  end

  defp api_key_only_endpoint?(%RawEndpoint{exchange: "binance", path: path})
       when path in ["historicalTrades", "userDataStream", "listenKey", "userListenToken"],
       do: true

  defp api_key_only_endpoint?(_endpoint), do: false

  defp signed_endpoint?(%RawEndpoint{exchange: "binance", api_path: [api | _], path: path}) do
    cond do
      api == "private" -> true
      api == "eapiPrivate" -> true
      api == "sapi" and path != "system/status" -> true
      api in ["sapiV2", "sapiV3", "sapiV4"] -> true
      api in ["dapiPrivate", "dapiPrivateV2"] -> true
      api in ["fapiPrivate", "fapiPrivateV2", "fapiPrivateV3"] -> true
      api == "papi" and path != "ping" -> true
      api == "papiV2" -> true
      true -> false
    end
  end

  defp signed_endpoint?(_endpoint), do: false

  defp api_key_only_request(%RawEndpoint{} = endpoint, params, opts) do
    with {:ok, api_key} <- credential(opts, :api_key, "BINANCE_API_KEY"),
         {:ok, base_url} <- base_url(endpoint, opts) do
      {path, query_params} = interpolate_path(endpoint.path, params)
      query = encode_query(query_params)
      url = base_url <> "/" <> path

      headers = [
        {"X-MBX-APIKEY", api_key},
        {"Content-Type", "application/x-www-form-urlencoded"}
      ]

      cond do
        endpoint.http_method == "GET" and query == "" ->
          {:ok, url, headers, ""}

        endpoint.http_method == "GET" ->
          {:ok, url <> "?" <> query, headers, ""}

        true ->
          {:ok, url, headers, query}
      end
    end
  end

  defp signed_request(%RawEndpoint{} = endpoint, params, opts) do
    with {:ok, api_key} <- credential(opts, :api_key, "BINANCE_API_KEY"),
         {:ok, api_secret} <- credential(opts, :api_secret, "BINANCE_API_SECRET"),
         {:ok, base_url} <- base_url(endpoint, opts) do
      {path, query_params} = interpolate_path(endpoint.path, params)

      query_params =
        query_params
        |> normalize_params()
        |> prepare_signed_params(endpoint, path, opts)
        |> put_recv_window(opts)
        |> Map.put_new("timestamp", timestamp(opts))

      query = signed_query_payload(endpoint, path, query_params)
      signature = hmac_sha256(api_secret, query)
      signed_query = query <> "&signature=" <> signature
      url = base_url <> "/" <> path
      headers = [{"X-MBX-APIKEY", api_key}]

      if endpoint.http_method in ["GET", "DELETE"] do
        {:ok, url <> "?" <> signed_query, headers, ""}
      else
        {:ok, url, [{"Content-Type", "application/x-www-form-urlencoded"} | headers],
         signed_query}
      end
    end
  end

  defp signed_query_payload(%RawEndpoint{api_path: ["sapi" | _]}, "asset/dust", query_params) do
    encode_query_with_array_repeat(query_params)
  end

  defp signed_query_payload(%RawEndpoint{http_method: "DELETE"}, "batchOrders", query_params) do
    order_id_list = Map.get(query_params, "orderidlist", [])

    orig_client_order_id_list =
      Map.get(
        query_params,
        "origclientorderidlist",
        Map.get(query_params, "origClientOrderIdList", [])
      )

    base_params =
      query_params
      |> Map.delete("orderidlist")
      |> Map.delete("origclientorderidlist")
      |> Map.delete("origClientOrderIdList")
      |> maybe_encode_batch_order_symbol()

    base_query = raw_encode_query(base_params)

    [
      base_query,
      batch_order_id_list_query("orderidlist", order_id_list),
      batch_order_id_list_query("origclientorderidlist", orig_client_order_id_list, true)
    ]
    |> Enum.reject(&(&1 == ""))
    |> Enum.join("&")
  end

  defp signed_query_payload(_endpoint, path, query_params) do
    if raw_encoded_signed_path?(path) do
      raw_encode_query(query_params)
    else
      encode_query(query_params)
    end
  end

  defp prepare_signed_params(
         %{"batchOrders" => batch_orders} = params,
         %RawEndpoint{api_path: ["fapiPrivate"], http_method: "POST"},
         "batchOrders",
         opts
       )
       when is_list(batch_orders) do
    checked_batch_orders =
      Enum.map(batch_orders, fn batch_order ->
        batch_order
        |> normalize_params()
        |> ensure_client_order_id(opts, "future", "x-xcKtGhcu")
      end)

    Map.put(params, "batchOrders", Jason.encode!(checked_batch_orders))
  end

  defp prepare_signed_params(
         %{"batchOrders" => batch_orders} = params,
         _endpoint,
         "batchOrders",
         _opts
       )
       when is_list(batch_orders) do
    Map.put(params, "batchOrders", Jason.encode!(batch_orders))
  end

  defp prepare_signed_params(params, %RawEndpoint{http_method: "POST"} = endpoint, path, opts)
       when path in ["order", "sor/order"] do
    if Map.has_key?(params, "newClientOrderId") do
      params
    else
      market_type = if spot_or_margin_api?(endpoint.api_path), do: "spot", else: "future"
      default_id = if market_type == "spot", do: "x-TKT5PX2F", else: "x-xcKtGhcu"

      Map.put(
        params,
        "newClientOrderId",
        broker_id(opts, market_type, default_id) <> uuid22(opts)
      )
    end
  end

  defp prepare_signed_params(params, _endpoint, _path, _opts), do: params

  defp put_recv_window(%{"recvWindow" => _recv_window} = params, _opts), do: params

  defp put_recv_window(params, opts) do
    Map.put(
      params,
      "recvWindow",
      Keyword.get(opts, :recv_window, Keyword.get(opts, :recvWindow, 10_000))
    )
  end

  defp ensure_client_order_id(
         %{"newClientOrderId" => _new_client_order_id} = params,
         _opts,
         _market_type,
         _default_id
       ),
       do: params

  defp ensure_client_order_id(params, opts, market_type, default_id) do
    Map.put(params, "newClientOrderId", broker_id(opts, market_type, default_id) <> uuid22(opts))
  end

  defp spot_or_margin_api?([api | _]), do: String.contains?(api, "sapi") or api == "private"
  defp spot_or_margin_api?(_api_path), do: false

  defp broker_id(opts, market_type, default_id) do
    opts
    |> Keyword.get(:broker, %{})
    |> normalize_params()
    |> Map.get(market_type, default_id)
    |> to_string()
  end

  defp uuid22(opts) do
    case Keyword.get(opts, :uuid22) do
      value when is_binary(value) ->
        value

      fun when is_function(fun, 0) ->
        fun.()

      nil ->
        16
        |> :crypto.strong_rand_bytes()
        |> Base.url_encode64(padding: false)
        |> binary_part(0, 22)
    end
  end

  defp raw_encoded_signed_path?(path) do
    path == "batchOrders" or
      String.contains?(path, "sub-account") or
      path == "capital/withdraw/apply" or
      String.contains?(path, "staking") or
      String.contains?(path, "simple-earn")
  end

  defp maybe_encode_batch_order_symbol(%{"symbol" => symbol} = params) do
    Map.put(params, "symbol", URI.encode_www_form(to_string(symbol)))
  end

  defp maybe_encode_batch_order_symbol(params), do: params

  defp batch_order_id_list_query(_key, []), do: ""
  defp batch_order_id_list_query(key, values), do: batch_order_id_list_query(key, values, false)

  defp batch_order_id_list_query(_key, [], _quote_values), do: ""

  defp batch_order_id_list_query(key, values, quote_values) when is_list(values) do
    encoded_values =
      values
      |> Enum.map(&batch_order_id_value(&1, quote_values))
      |> Enum.join("%2C")

    key <> "=%5B" <> encoded_values <> "%5D"
  end

  defp batch_order_id_list_query(key, value, quote_values) do
    batch_order_id_list_query(key, [value], quote_values)
  end

  defp batch_order_id_value(value, true), do: "%22" <> to_string(value) <> "%22"
  defp batch_order_id_value(value, false), do: to_string(value)

  defp credential(opts, key, env_key) do
    value = Keyword.get(opts, key) || System.get_env(env_key)

    if is_binary(value) and value != "" do
      {:ok, value}
    else
      {:error, {:missing_credential, env_key}}
    end
  end

  defp normalize_params(params) when is_map(params) do
    Map.new(params, fn {key, value} -> {to_string(key), value} end)
  end

  defp normalize_params(params) when is_list(params) do
    Map.new(params, fn {key, value} -> {to_string(key), value} end)
  end

  defp timestamp(opts) do
    Keyword.get_lazy(opts, :timestamp, fn -> System.system_time(:millisecond) end)
  end

  defp hmac_sha256(secret, payload) do
    :hmac
    |> :crypto.mac(:sha256, secret, payload)
    |> Base.encode16(case: :lower)
  end

  defp interpolate_path(path, params) do
    Regex.scan(~r/{([^}]+)}/, path)
    |> Enum.reduce({path, params}, fn [_placeholder, key], {current_path, current_params} ->
      value = param_value!(current_params, key)
      encoded_value = value |> to_string() |> URI.encode_www_form()

      {
        String.replace(current_path, "{" <> key <> "}", encoded_value),
        delete_param(current_params, key)
      }
    end)
  end

  defp param_value!(params, key) when is_map(params) do
    Map.get(params, key, Map.get(params, String.to_atom(key)))
  end

  defp param_value!(params, key) when is_list(params) do
    Keyword.get(params, String.to_atom(key)) || Keyword.get(params, key)
  end

  defp delete_param(params, key) when is_map(params) do
    params
    |> Map.delete(key)
    |> Map.delete(String.to_atom(key))
  end

  defp delete_param(params, key) when is_list(params) do
    params
    |> Keyword.delete(String.to_atom(key))
    |> Keyword.delete(key)
  end

  defp decode_response(status, body) when status in 200..299 do
    decode_body(body)
  end

  defp decode_response(status, body) do
    {:error, {:http_error, status, body}}
  end

  defp decode_body(""), do: {:ok, %{}}

  defp decode_body(body) do
    body
    |> Jason.decode()
    |> case do
      {:ok, decoded} -> {:ok, decoded}
      {:error, _reason} -> {:ok, body}
    end
  end

  defp ensure_supported_method(%RawEndpoint{http_method: method})
       when method in ["GET", "POST", "PUT", "DELETE"],
       do: :ok

  defp ensure_supported_method(%RawEndpoint{http_method: method}),
    do: {:error, {:unsupported_http_method, method}}

  defp default_transport("GET", url, headers, _body) do
    request = {String.to_charlist(url), charlist_headers(headers)}

    case :httpc.request(:get, request, [], body_format: :binary) do
      {:ok, {{_version, status, _reason}, response_headers, response_body}} ->
        {:ok, status, response_headers, response_body}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp default_transport("POST", url, headers, body) do
    request =
      {String.to_charlist(url), charlist_headers(headers), ~c"application/x-www-form-urlencoded",
       body}

    case :httpc.request(:post, request, [], body_format: :binary) do
      {:ok, {{_version, status, _reason}, response_headers, response_body}} ->
        {:ok, status, response_headers, response_body}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp default_transport("PUT", url, headers, body) do
    request =
      {String.to_charlist(url), charlist_headers(headers), ~c"application/x-www-form-urlencoded",
       body}

    case :httpc.request(:put, request, [], body_format: :binary) do
      {:ok, {{_version, status, _reason}, response_headers, response_body}} ->
        {:ok, status, response_headers, response_body}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp default_transport("DELETE", url, headers, _body) do
    request = {String.to_charlist(url), charlist_headers(headers)}

    case :httpc.request(:delete, request, [], body_format: :binary) do
      {:ok, {{_version, status, _reason}, response_headers, response_body}} ->
        {:ok, status, response_headers, response_body}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp default_transport(method, _url, _headers, _body),
    do: {:error, {:unsupported_http_method, method}}

  defp charlist_headers(headers) do
    Enum.map(headers, fn {key, value} -> {String.to_charlist(key), String.to_charlist(value)} end)
  end
end