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, %{}), base_url: endpoint[:base_url], market_type: endpoint[:market_type], response_transformer: endpoint[:response_transformer], 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: compute_prefix_strategy(spec.path_prefix, path) } 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__() # Build params: defaults → function args → extra opts params # Strip defaults that collide with non-nil function args (via param_mappings) params = config.default_params |> 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) # 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() |> Helpers.denormalize_symbol_param(spec, config.market_type) # 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) # 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 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.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 String.contains?(path, ":") -> :colon String.contains?(path, "{") -> :curly 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 @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