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