defmodule CCXT.Generator.Dispatch do @moduledoc """ Shared runtime dispatcher for generated endpoint functions. Instead of inlining ~30 lines of glue code per endpoint (87-99 times per exchange), generated functions become thin wrappers that call `call/5`. This module contains the shared pipeline logic: param merging, emulation dispatch, path interpolation, prefix handling, and HTTP execution. ## Architecture - **`build_config/3` / `build_configs/2`** — Called at compile time to pre-compute per-endpoint configuration (path style, prefix strategy, reversed mappings, etc.) - **`call/5`** — Called at runtime by thin wrapper functions. Receives the exchange module, endpoint name, pre-filtered param map, user opts, and optional parser mapping. ## Pipeline (mirrors endpoints.ex:206-271) 1. Look up endpoint config and spec from exchange module 2. Merge params: defaults → function args → opts[:params] 3. Emulation dispatch (return early if emulated) 4. On `:passthrough`: - Resolve generated placeholders - Denormalize symbol param - Convert OHLCV timestamps (if applicable) - Interpolate path placeholders - Apply endpoint param mappings - Apply path prefix strategy - Build client opts - Execute HTTP request """ alias CCXT.Generator.Helpers # OHLCV endpoints that need timestamp conversion and timeframe translation @ohlcv_endpoints [ :fetch_ohlcv, :fetch_index_ohlcv, :fetch_mark_ohlcv, :fetch_premium_index_ohlcv ] # Pre-compiled regexes for path interpolation @colon_regex ~r/:(\w+)/ @curly_regex ~r/\{([\w-]+)\}/ # ============================================================================ # Compile-time config builders # ============================================================================ @doc """ Builds endpoint configs for all endpoints in a spec. Called at compile time by `CCXT.Generator` to produce a map of `%{endpoint_name => config}` stored as a module attribute. ## Parameters - `spec` - The exchange specification - `pipeline` - Optional pipeline keyword list with `:coercer` and `:parsers` """ @spec build_configs(CCXT.Spec.t(), keyword()) :: %{atom() => map()} def build_configs(spec, pipeline \\ []) do spec.endpoints |> Enum.filter(fn endpoint -> Map.get(spec.has, endpoint[:name], true) end) |> Map.new(fn endpoint -> {endpoint[:name], build_config(endpoint, spec, pipeline)} end) end @doc """ Builds a single endpoint config map. Pre-computes values that would otherwise be recalculated on every call: path style, reversed mappings, prefix strategy, and response type. ## Parameters - `endpoint` - Endpoint map from spec (`:name`, `:method`, `:path`, etc.) - `spec` - The exchange specification - `pipeline` - Optional pipeline keyword list with `:coercer` """ @spec build_config(map(), CCXT.Spec.t(), keyword()) :: map() def build_config(endpoint, spec, pipeline \\ []) do path = endpoint[:path] param_mappings = Map.get(endpoint, :param_mappings, %{}) coercer = pipeline[:coercer] name = endpoint[:name] path_style = detect_path_style(path) %{ method: endpoint[:method], path: path, auth: endpoint[:auth], param_mappings: param_mappings, default_params: Map.get(endpoint, :default_params, %{}), path_params: Map.get(endpoint, :path_params, %{}), base_url: endpoint[:base_url], market_type: endpoint[:market_type], response_transformer: endpoint[:response_transformer], pagination: endpoint[:pagination], response_type: if(coercer, do: coercer.infer_response_type(name)), coercer: coercer, ohlcv?: name in @ohlcv_endpoints, path_style: path_style, reversed_mappings: if(path_style == :curly, do: reverse_mappings(param_mappings), else: %{}), prefix_strategy: if endpoint[:base_url] do # Custom api_section base_url already includes the correct path prefix :passthrough else compute_prefix_strategy(spec.path_prefix, path) end } end # ============================================================================ # Runtime dispatch # ============================================================================ @doc """ Runtime dispatch entry point for generated endpoint functions. Called by thin wrappers generated in 227c. Executes the full pipeline: param merging, emulation check, path interpolation, and HTTP request. ## Parameters - `exchange_module` - The generated exchange module (e.g., `MyApp.Bybit`) - `endpoint_name` - Endpoint atom (e.g., `:fetch_ticker`) - `param_map` - Pre-filtered parameter map from function args (nil values excluded) - `opts` - User-provided keyword options - `parser_mapping` - Optional parser mapping for response coercion """ @spec call(module(), atom(), map(), keyword(), term()) :: {:ok, term()} | {:error, CCXT.Error.t()} def call(exchange_module, endpoint_name, param_map, opts, parser_mapping \\ nil) do config = exchange_module.__endpoint_config__(endpoint_name) spec = exchange_module.__ccxt_spec__() # Resolve market-type-aware defaults before merging. # When default_params contains %{_by_market_type: %{spot: ..., swap: ...}}, # select the correct variant based on opts[:market_type] or config.market_type. resolved_defaults = resolve_defaults(config.default_params, config.market_type, opts) # Build params: defaults → function args → extra opts params # Strip defaults that collide with non-nil function args (via param_mappings) params = resolved_defaults |> Helpers.strip_overridden_defaults(param_map, config.param_mappings) |> Map.merge(param_map) |> Map.merge(Map.new(Keyword.get(opts, :params, []))) # Capture credentials (nil for public endpoints) credentials = if config.auth, do: Keyword.get(opts, :credentials) # Inject original param_map into opts so the normalization pipeline # can populate fields derived from function parameters (e.g., symbol, since). # See Task 229c: {:param, key} instructions in ResponseParser. opts = Keyword.put(opts, :_param_map, param_map) # Emulated method dispatch (REST scope) case CCXT.Emulation.dispatch(spec, endpoint_name, :rest, %{ exchange_module: exchange_module, params: params, opts: opts, credentials: credentials }) do :passthrough -> execute_passthrough(spec, config, params, opts, credentials, parser_mapping) {:ok, result} -> {:ok, result} {:error, _} = error -> error end end # ============================================================================ # Passthrough pipeline (HTTP execution path) # ============================================================================ @doc false @spec execute_passthrough(CCXT.Spec.t(), map(), map(), keyword(), term(), term()) :: {:ok, term()} | {:error, CCXT.Error.t()} defp execute_passthrough(spec, config, params, opts, credentials, parser_mapping) do params = params |> Helpers.resolve_generated_placeholders() |> inject_path_params(config.path_params) market_type = Keyword.get(opts, :market_type, config.market_type) case Helpers.denormalize_symbol_param(params, spec, market_type) do {:error, :no_symbol_format} -> {:error, CCXT.Error.not_supported( message: "Exchange #{spec.id} has no symbol format — " <> "loadMarkets() likely failed during extraction. " <> "Endpoints without symbol params still work.", exchange: String.to_existing_atom(spec.id) )} params -> execute_passthrough_pipeline(spec, config, params, opts, credentials, parser_mapping) end end @doc false # Runs the passthrough pipeline after symbol denormalization succeeds. defp execute_passthrough_pipeline(spec, config, params, opts, credentials, parser_mapping) do # Convert OHLCV timestamps if needed params = if config.ohlcv? do Helpers.convert_ohlcv_timestamps(params, spec.ohlcv_timestamp_resolution) else params end # Translate unified timeframe values to exchange-native format (e.g., "1h" → "60" for Bybit) params = Helpers.translate_timeframe(params, spec.timeframes, config.market_type) # Interpolate path, apply mappings, prepend prefix base_path = interpolate_path(config.path, config.path_style, params, config.reversed_mappings) params = Helpers.apply_endpoint_mappings(params, config.param_mappings) path = build_prefixed_path(config.prefix_strategy, base_path) # Execute HTTP request with response transformation and optional coercion client_opts = build_client_opts(opts, params, credentials, config.auth, config.base_url) Helpers.execute_request( spec, config.method, path, client_opts, config.response_transformer, config.response_type, parser_mapping, opts, config.coercer ) end # ============================================================================ # Path interpolation (runtime, data-driven) # ============================================================================ @doc false # Interpolates path placeholders using pre-detected path style. # sobelow_skip ["DOS.StringToAtom"] @spec interpolate_path(String.t(), atom(), map(), map()) :: String.t() defp interpolate_path(path, :plain, _params, _reversed_mappings), do: path defp interpolate_path(path, :colon, params, _reversed_mappings) do Regex.replace(@colon_regex, path, fn _match, key -> # Safe: path placeholders come from trusted spec files loaded at compile time # sobelow_skip ["DOS.StringToAtom"] key_atom = String.to_atom(key) to_string(Map.get(params, key_atom, "")) end) end defp interpolate_path(path, :curly, params, reversed_mappings) do Regex.replace(@curly_regex, path, fn _match, key -> # First check reversed param_mappings, then try direct atom conversion # Safe: path placeholders come from trusted spec files loaded at compile time # sobelow_skip ["DOS.StringToAtom"] param_key = Map.get(reversed_mappings, key, String.to_atom(key)) Helpers.get_path_param(params, param_key, key) end) end # ============================================================================ # Path param injection (runtime, transform-based) # ============================================================================ @doc false # Injects path placeholder values derived from unified params with transforms. # Runs BEFORE denormalize_symbol_param so :base/:quote can split "BTC/BRL". @spec inject_path_params(map(), map()) :: map() defp inject_path_params(params, path_params) when map_size(path_params) == 0, do: params defp inject_path_params(params, path_params) do Enum.reduce(path_params, params, fn {placeholder, %{source: source} = config}, acc -> source_value = to_string(Map.get(acc, source, "")) transformed = apply_path_transform(source_value, Map.get(config, :transform)) Map.put(acc, placeholder, transformed) end) end @doc false @spec apply_path_transform(String.t(), atom() | nil) :: String.t() defp apply_path_transform(value, :base), do: value |> String.split("/") |> List.first("") defp apply_path_transform(value, :quote) do case String.split(value, "/") do [_, quote_currency | _] -> quote_currency _ -> "" end end defp apply_path_transform(value, _), do: value # ============================================================================ # Path prefix application (runtime, pre-computed strategy) # ============================================================================ @doc false # Applies pre-computed prefix strategy to the interpolated base path. @spec build_prefixed_path(atom() | tuple(), String.t()) :: String.t() defp build_prefixed_path(:passthrough, base_path), do: base_path defp build_prefixed_path({:concat, prefix}, base_path), do: prefix <> base_path defp build_prefixed_path({:strip_slash, prefix}, base_path) do prefix <> String.slice(base_path, 1..-1//1) end defp build_prefixed_path({:version_override, new_prefix, version_prefix}, base_path) do stripped = String.replace_prefix(base_path, version_prefix, "") new_prefix <> String.trim_leading(stripped, "/") end # ============================================================================ # Client opts builder # ============================================================================ @doc false # Builds keyword list for HTTP client from user opts, processed params, # credentials, and endpoint-specific base_url. @spec build_client_opts(keyword(), map(), term(), boolean(), String.t() | nil) :: keyword() defp build_client_opts(opts, params, credentials, auth, base_url) do client_opts = opts |> Keyword.delete(:params) |> Keyword.delete(:credentials) |> Keyword.delete(:market_type) |> Keyword.delete(:_param_map) |> Keyword.put(:params, params) client_opts = if auth, do: Keyword.put(client_opts, :credentials, credentials), else: client_opts if base_url, do: Keyword.put(client_opts, :base_url, base_url), else: client_opts end # ============================================================================ # Config computation helpers (compile-time) # ============================================================================ @doc false # Detects path placeholder style from path string. @spec detect_path_style(String.t()) :: :plain | :colon | :curly defp detect_path_style(path) do cond do # Check curly BEFORE colon: paths like /candles/trade:{timeframe}:{symbol}/hist # have both, but colons are literal delimiters while curly braces are placeholders String.contains?(path, "{") -> :curly String.contains?(path, ":") -> :colon true -> :plain end end @doc false # Reverses param_mappings for curly-brace path interpolation. # %{"id" => "order-id"} → %{"order-id" => :id} # sobelow_skip ["DOS.StringToAtom"] @spec reverse_mappings(map()) :: map() defp reverse_mappings(param_mappings) do for {unified, request} <- param_mappings, into: %{} do {request, String.to_atom(unified)} end end # ============================================================================ # Market-type-aware default resolution # ============================================================================ @doc false # Resolves market-type-variant defaults at runtime. # When default_params contains %{_by_market_type: %{spot: %{...}, swap: %{...}}}, # selects the correct variant based on opts[:market_type] or config.market_type. # Falls back to empty map if the requested market type has no variant. @spec resolve_defaults(map(), atom() | nil, keyword()) :: map() defp resolve_defaults(%{_by_market_type: variants}, default_market_type, opts) do market_type = Keyword.get(opts, :market_type, default_market_type) Map.get(variants, market_type, %{}) end defp resolve_defaults(defaults, _default_market_type, _opts) when is_map(defaults), do: defaults defp resolve_defaults(_defaults, _default_market_type, _opts), do: %{} @doc false # Pre-computes prefix handling strategy from spec.path_prefix and endpoint path. @spec compute_prefix_strategy(String.t(), String.t()) :: atom() | tuple() defp compute_prefix_strategy("", _path), do: :passthrough defp compute_prefix_strategy(prefix, path) do cond do String.starts_with?(path, prefix) -> :passthrough Regex.match?(~r{^/v\d+/}, path) and Regex.match?(~r{v\d+}, prefix) -> [_, path_ver] = Regex.run(~r{^/(v\d+)}, path) new_prefix = Regex.replace(~r{v\d+}, prefix, path_ver, global: false) version_prefix = "/" <> path_ver {:version_override, new_prefix, version_prefix} String.ends_with?(prefix, "/") and String.starts_with?(path, "/") -> {:strip_slash, prefix} true -> {:concat, prefix} end end end