Current section
Files
Jump to
Current section
Files
lib/ccxt/dispatch.ex
defmodule CCXT.Dispatch do
@moduledoc """
Shared request dispatcher for generated exchange endpoint functions.
All generated endpoint functions delegate to `call/4`, which handles:
1. **Path interpolation** — replaces `{param}` templates with values from params
2. **Base URL resolution** — navigates `exchange.base_urls` using endpoint sections
3. **Signing** — authenticates private endpoint requests via `CCXT.Signing.sign/4`
4. **HTTP delegation** — calls `CCXT.HTTP.request/4` or `CCXT.HTTP.signed_request/4`
## Future phases
- **Phase 5**: Response parsing (field mapping to unified structs)
- **Task 17**: Symbol denormalization (unified → exchange-specific format)
"""
alias CCXT.Error
alias CCXT.Exchange
alias CCXT.HTTP
alias CCXT.Signing
require Logger
@typedoc "Compile-time endpoint configuration from spec"
@type endpoint_config :: %{
name: atom(),
method: atom(),
path: String.t(),
sections: [String.t()],
weight: number(),
url_prefix: String.t(),
authenticated: boolean()
}
@doc """
Dispatches a request to an exchange endpoint.
Resolves the base URL from the endpoint's sections, interpolates path
templates, and delegates to `CCXT.HTTP.request/4`.
## Parameters
- `exchange` — `%CCXT.Exchange{}` runtime configuration
- `endpoint_config` — compile-time endpoint map with `:name`, `:method`, `:path`, `:sections`, `:weight`
- `params` — request parameters (query for GET/HEAD/DELETE, body for POST/PUT/PATCH)
- `opts` — passed through to `CCXT.HTTP.request/4` (`:timeout`, `:headers`, etc.)
## Examples
config = %{name: :public_get_v5_market_tickers, method: :get,
path: "v5/market/tickers", sections: ["public"], weight: 5}
CCXT.Dispatch.call(exchange, config, %{"category" => "spot"})
"""
@spec call(Exchange.t(), endpoint_config(), map(), keyword()) ::
{:ok, HTTP.response()} | {:error, Error.t()}
def call(%Exchange{} = exchange, %{} = endpoint_config, params \\ %{}, opts \\ []) do
%{method: method, path: path_template, sections: sections} = endpoint_config
{path, remaining_params} = interpolate_path(path_template, params)
base_url = resolve_base_url(sections, exchange.base_urls)
url_prefix = Map.get(endpoint_config, :url_prefix, "/")
weight = Map.get(endpoint_config, :weight, 1)
if Map.get(endpoint_config, :authenticated, false) do
sign_and_request(exchange, method, url_prefix <> path, remaining_params, base_url, opts, weight)
else
http_opts = Keyword.merge(opts, base_url: base_url, params: remaining_params, endpoint_weight: weight)
HTTP.request(exchange, method, url_prefix <> path, http_opts)
end
end
# ---------------------------------------------------------------------------
# Signing Integration
#
# Private endpoints are signed via CCXT.Signing.sign/4 before HTTP execution.
# Visibility is spec-driven via `structure.authenticated_sections` (schema 1.7.1+),
# materialized onto each endpoint config as `:authenticated` at build time.
# Signing pattern/config come from the exchange struct, populated at compile time
# (generator) and runtime (Exchange.new/2) by the Classifier.
# ---------------------------------------------------------------------------
# Validates credentials and signing pattern, signs the request, delegates to HTTP.
defp sign_and_request(exchange, method, path, params, base_url, opts, weight) do
with {:ok, credentials} <- require_credentials(exchange),
{:ok, {pattern, config}} <- require_signing_pattern(exchange) do
signing_request = %{
method: method,
path: path,
body: nil,
params: params
}
signed = Signing.sign(pattern, signing_request, credentials, config)
HTTP.signed_request(exchange, signed, base_url, Keyword.put(opts, :endpoint_weight, weight))
end
end
defp require_credentials(%Exchange{credentials: nil, id: id}) do
{:error,
Error.authentication_error(
message: "Credentials required for private endpoint",
exchange: id
)}
end
defp require_credentials(%Exchange{credentials: credentials}), do: {:ok, credentials}
defp require_signing_pattern(%Exchange{signing_pattern: nil, id: id}) do
{:error,
Error.authentication_error(
message: "No signing pattern configured for exchange",
exchange: id
)}
end
# TODO: signing_config should always be a map via Exchange.new/2 and the generator macro,
# but manual struct construction can leave it nil. Consider raising here instead of defaulting.
defp require_signing_pattern(%Exchange{signing_pattern: pattern, signing_config: config}) do
{:ok, {pattern, config || %{}}}
end
# ---------------------------------------------------------------------------
# Path Interpolation
#
# Replaces `{param}` placeholders in endpoint paths with values from the
# params map. Consumed params are removed to avoid duplication as query/body.
# Common pattern across the CCXT universe (e.g., "orders/{id}", "{settle}/candlesticks").
# ---------------------------------------------------------------------------
@doc "Replaces `{param}` placeholders in path with values from params, returning remaining params."
@spec interpolate_path(String.t(), map()) :: {String.t(), map()}
def interpolate_path(path, params) when map_size(params) == 0, do: {path, params}
def interpolate_path(path, params) do
# Fast path: no templates
if String.contains?(path, "{") do
do_interpolate(path, params, %{}, %{}, path)
else
{path, params}
end
end
# Scans for {param} placeholders, replaces with param values, tracks consumed keys.
# Missing path params are preserved as-is so the exchange returns a descriptive error.
# Explicit nil values raise ArgumentError — nil is a caller bug, not "missing".
# `original_path` is threaded through recursion so warnings always reference the full
# input template, not a recursed suffix after `is_map_key(skipped, ...)` splits.
# Note: Duplicate placeholders in one path (e.g., "{id}/copy/{id}") would leave the
# second un-interpolated after the first consumes the param. No known spec does this.
defp do_interpolate(path, params, consumed, skipped, original_path) do
case Regex.run(~r/\{([\w-]+)\}/, path) do
[_placeholder, param_name] when is_map_key(skipped, param_name) ->
[before, rest] = String.split(path, "{#{param_name}}", parts: 2)
{interpolated_rest, remaining} = do_interpolate(rest, params, consumed, skipped, original_path)
{before <> "{#{param_name}}" <> interpolated_rest, remaining}
[placeholder, param_name] ->
case lookup_param(params, param_name) do
:not_found ->
Logger.warning(
"CCXT.Dispatch: path param '#{param_name}' missing for path '#{original_path}' — " <>
"placeholder '#{placeholder}' preserved; exchange will likely return a descriptive error"
)
do_interpolate(path, params, consumed, Map.put(skipped, param_name, true), original_path)
nil ->
raise ArgumentError,
"path param '#{param_name}' is nil — pass a value or omit the key"
value ->
new_path = String.replace(path, placeholder, to_string(value), global: false)
do_interpolate(new_path, params, Map.put(consumed, param_name, true), skipped, original_path)
end
nil ->
remaining = remove_consumed(params, consumed)
{path, remaining}
end
end
# Looks up a param by string key, falling back to atom key.
# Returns :not_found when the param is absent from the map.
defp lookup_param(params, name) do
case Map.fetch(params, name) do
{:ok, value} ->
value
:error ->
atom_key = String.to_existing_atom(name)
case Map.fetch(params, atom_key) do
{:ok, value} -> value
:error -> :not_found
end
end
rescue
ArgumentError -> :not_found
end
# Removes consumed path params from the params map (handles both string and atom keys)
defp remove_consumed(params, consumed) when map_size(consumed) == 0, do: params
defp remove_consumed(params, consumed) do
Map.reject(params, fn {key, _value} ->
key_str = if is_atom(key), do: Atom.to_string(key), else: key
is_map_key(consumed, key_str)
end)
end
# ---------------------------------------------------------------------------
# Base URL Resolution
#
# Navigates exchange.base_urls using the endpoint's sections list.
# Handles 4 URL patterns across the CCXT universe:
#
# Flat: %{"public" => "https://api.bybit.com"} — direct key lookup
# Distinct: %{"sapi" => "https://api.binance.com/sapi/v1"} — section IS the key
# Nested: %{"public" => %{"delivery" => "https://..."}} — walk sections
# Mixed: %{"spot" => "https://..."} — early-stop when string found
# ---------------------------------------------------------------------------
@doc "Navigates `base_urls` using endpoint sections to find the appropriate base URL."
@spec resolve_base_url([String.t()], map()) :: String.t()
def resolve_base_url(sections, base_urls) do
navigate_sections(sections, base_urls) || find_any_url(base_urls) || ""
end
# Walks sections into the URL map. Stops when a string (URL) is found.
defp navigate_sections([], _urls), do: nil
defp navigate_sections([section | rest], urls) when is_map(urls) do
case Map.get(urls, section) do
url when is_binary(url) -> url
nested when is_map(nested) and rest != [] -> navigate_sections(rest, nested)
nested when is_map(nested) -> find_any_url(nested)
nil -> navigate_sections(rest, urls)
end
end
defp navigate_sections(_sections, _urls), do: nil
# Fallback: find any string URL value in the map (recursive)
defp find_any_url(urls) when is_map(urls) do
Enum.find_value(urls, fn
{_key, value} when is_binary(value) -> value
{_key, value} when is_map(value) -> find_any_url(value)
_ -> nil
end)
end
defp find_any_url(_), do: nil
end