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