defmodule CCXT.Generator.Functions.Endpoints do @moduledoc """ Endpoint function generation for exchange modules. Generates thin wrapper functions that delegate to `CCXT.Generator.Dispatch.call/5` for the shared pipeline (param merging, path interpolation, HTTP execution). Also generates stubs for unsupported, failed-extraction, and emulated methods. """ alias CCXT.Generator.Functions.Docs alias CCXT.Generator.Functions.Typespecs alias CCXT.Spec # Parameters that are optional and get default nil value # These are commonly optional across CCXT's unified API methods: # - limit: pagination limit (fetchOrders, fetchOHLCV, etc.) # - since: start timestamp for historical data (fetchOrders, fetchOHLCV, etc.) # - until: end timestamp for historical data # - price: for market orders (createOrder) # - code: currency code (fetchBalance when fetching specific currency) # - trigger_price: for conditional orders # - stop_loss_price: for orders with stop loss # - take_profit_price: for orders with take profit @optional_params [:limit, :since, :until, :price, :code, :trigger_price, :stop_loss_price, :take_profit_price] # Inline priv path resolution to avoid compile-time dependency on CCXT.Priv # (which would cause cascading recompilation of ~200 exchange modules) # Method signatures loaded at compile time for generating extraction failure stubs # Use stdlib JSON (Elixir 1.18+) at compile time to avoid Jason dependency ordering issues @method_signatures_path (case :code.priv_dir(Mix.Project.config()[:app]) do {:error, :bad_name} -> [__DIR__, "..", "..", "..", "priv", "extractor/ccxt_method_signatures.json"] |> Path.join() |> Path.expand() priv when is_list(priv) -> Path.join(List.to_string(priv), "extractor/ccxt_method_signatures.json") end) @external_resource @method_signatures_path @method_signatures (case File.read(@method_signatures_path) do {:ok, content} -> case JSON.decode(content) do {:ok, %{"methods" => methods}} -> methods {:error, reason} -> IO.warn( "Failed to parse method signatures from #{@method_signatures_path}: #{inspect(reason)}" ) %{} end {:error, reason} -> IO.warn("Failed to load method signatures from #{@method_signatures_path}: #{inspect(reason)}") %{} end) # Inline priv path resolution to avoid compile-time dependency on CCXT.Priv # Emulated method list loaded at compile time for generating emulation stubs # Use stdlib JSON (Elixir 1.18+) at compile time to avoid Jason dependency ordering issues @emulated_methods_path (case :code.priv_dir(Mix.Project.config()[:app]) do {:error, :bad_name} -> [__DIR__, "..", "..", "..", "priv", "extractor/ccxt_emulated_methods.json"] |> Path.join() |> Path.expand() priv when is_list(priv) -> Path.join(List.to_string(priv), "extractor/ccxt_emulated_methods.json") end) @external_resource @emulated_methods_path @emulated_methods (case File.read(@emulated_methods_path) do {:ok, content} -> case JSON.decode(content) do {:ok, %{"emulated_methods" => methods}} -> methods {:ok, _} -> IO.warn("Unexpected format in #{@emulated_methods_path}: missing emulated_methods key") %{} {:error, reason} -> IO.warn( "Failed to parse emulated methods from #{@emulated_methods_path}: #{inspect(reason)}" ) %{} end {:error, reason} -> IO.warn("Failed to load emulated methods from #{@emulated_methods_path}: #{inspect(reason)}") %{} end) @doc """ Generates all endpoint functions from the spec. Returns AST for all functions defined in `spec.endpoints`, plus stub functions for methods that failed during extraction (e.g., inherited methods that don't work for this exchange class). """ @spec generate_endpoints(Spec.t(), keyword()) :: Macro.t() def generate_endpoints(spec, pipeline \\ []) do endpoint_fns = spec.endpoints |> Enum.map(&generate_endpoint_function(&1, spec, pipeline)) |> List.flatten() # Generate stubs for extraction failures failure_stubs = generate_extraction_failure_stubs(spec) # Generate stubs for emulated methods without HTTP endpoints emulated_stubs = generate_emulated_method_stubs(spec) endpoint_fns ++ failure_stubs ++ emulated_stubs end # Generate a function for a single endpoint @doc false @spec generate_endpoint_function(map(), Spec.t(), keyword()) :: Macro.t() | [] defp generate_endpoint_function(endpoint, spec, pipeline) do name = endpoint[:name] auth = endpoint[:auth] params = Map.get(endpoint, :params, []) # Get capability from spec.has to determine if this should be generated has_capability = Map.get(spec.has, name, true) if has_capability do # Conditional response_type inference via pipeline coercer coercer = pipeline[:coercer] response_type = if coercer, do: coercer.infer_response_type(name) endpoint_opts = %{ method: endpoint[:method], path: endpoint[:path], auth: auth, params: params, param_mappings: Map.get(endpoint, :param_mappings, %{}), required_params: Map.get(endpoint, :required_params, []), default_params: Map.get(endpoint, :default_params, %{}), base_url: endpoint[:base_url], market_type: endpoint[:market_type], response_transformer: endpoint[:response_transformer], response_type: response_type } generate_endpoint(name, endpoint_opts, spec, pipeline) else # Generate a stub that returns {:error, :not_supported} generate_unsupported_endpoint(name, auth, params) end end # Generates a thin wrapper that delegates to Dispatch.call/5. # All pipeline logic (param merging, emulation, path interpolation, HTTP execution) # lives in Dispatch at runtime, not inlined per-endpoint. @doc false @spec generate_endpoint(atom(), map(), Spec.t(), keyword()) :: Macro.t() defp generate_endpoint(name, opts, spec, pipeline) do %{auth: auth, params: params, required_params: required_params, response_type: response_type} = opts {args, param_map} = build_args_and_params(params, auth, required_params) doc = Docs.generate_doc(name, params, auth, spec) spec_signature = Typespecs.generate_typespec_signature(name, params, auth, required_params) return_type_ast = Typespecs.ok_error_return_type_ast(name) opts_var = {:opts, [], Elixir} creds_var = {:credentials, [], Elixir} # Resolve parser attribute for pipeline (nil when no pipeline — always nil in ccxt_ex) coercer = pipeline[:coercer] parsers_mod = pipeline[:parsers] parser_ast = if parsers_mod && response_type do parser_attr = parsers_mod.parser_attr_for_type(coercer.singularize(response_type)) if parser_attr, do: {:@, [], [{parser_attr, [], Elixir}]} end # Auth endpoints inject credentials into opts for Dispatch dispatch_opts_ast = if auth do quote do: Keyword.put(unquote(opts_var), :credentials, unquote(creds_var)) else opts_var end quote do @spec unquote(spec_signature) :: unquote(return_type_ast) @doc unquote(doc) def unquote(name)(unquote_splicing(args)) do # credo:disable-for-next-line Credo.Check.Design.AliasUsage CCXT.Generator.Dispatch.call( __MODULE__, unquote(name), unquote(param_map), unquote(dispatch_opts_ast), unquote(parser_ast) ) end end end # Generate a stub for unsupported endpoints @doc false @spec generate_unsupported_endpoint(atom(), boolean(), [atom()]) :: Macro.t() defp generate_unsupported_endpoint(name, auth, params) do {args, _param_map} = build_args_and_params(params, auth, []) spec_signature = Typespecs.generate_typespec_signature(name, params, auth, []) return_type_ast = Typespecs.ok_error_return_type_ast(name) quote do @spec unquote(spec_signature) :: unquote(return_type_ast) @doc false def unquote(name)(unquote_splicing(args)) do {:error, %CCXT.Error{ type: :exchange_error, message: "#{unquote(name)} not supported by this exchange" }} end end end # Builds function arguments and parameter map AST for endpoint functions. # # Returns a tuple of {args_ast, param_map_ast} where: # - args_ast: List of function argument AST nodes (credentials if auth, params, opts) # - param_map_ast: AST that builds a map from param names to their runtime values, # filtering out nil values for optional params # # Uses consistent Elixir context for all variables so they can be referenced in the function body. # `required_params` overrides @optional_params - if a param is in both, it's required. @doc false @spec build_args_and_params([atom()], boolean(), [atom()]) :: {list(), Macro.t()} defp build_args_and_params(params, auth, required_params) do # Start with credentials if auth required (using Elixir context) base_args = if auth, do: [{:credentials, [], Elixir}], else: [] # Add params as individual arguments (with defaults for optional ones) # A param gets default nil if it's in @optional_params AND NOT in required_params param_args = Enum.map(params, fn param -> is_optional = param in @optional_params and param not in required_params if is_optional do {:\\, [], [{param, [], Elixir}, nil]} else {param, [], Elixir} end end) # Always add opts at the end opts_arg = {:\\, [], [{:opts, [], Elixir}, []]} args = base_args ++ param_args ++ [opts_arg] # Build AST that creates the parameter map at runtime # Generate: [{:symbol, symbol}, {:limit, limit}, ...] |> Enum.reject(...) |> Map.new() param_pairs = Enum.map(params, fn param -> # Generate a {key, var} tuple AST {:{}, [], [param, {param, [], Elixir}]} end) param_map_ast = quote do unquote(param_pairs) |> Enum.reject(fn {_k, v} -> is_nil(v) end) |> Map.new() end {args, param_map_ast} end # ============================================================================= # Extraction Failure Stub Generation # # When CCXT reports has.X: true but the method fails during extraction # (e.g., "fetchPosition() supports option markets only"), we generate a # stub function that returns the helpful error message instead of leaving # the user with a confusing "function undefined" compile error. # ============================================================================= @doc false @spec generate_extraction_failure_stubs(Spec.t()) :: [Macro.t()] defp generate_extraction_failure_stubs(spec) do failures = get_in(spec.endpoint_extraction_stats, ["failures"]) || [] # Get names of endpoints that were successfully extracted existing_endpoints = MapSet.new(spec.endpoints, & &1[:name]) failures |> Enum.map(&parse_failure/1) |> Enum.reject(fn nil -> true {name, _msg} -> MapSet.member?(existing_endpoints, name) end) |> Enum.map(fn {name, message} -> generate_failure_stub(name, message, spec) end) end # Parse failure entry into {atom_name, error_message} @doc false @spec parse_failure(map()) :: {atom(), String.t()} | nil defp parse_failure(%{"method" => method, "error" => error}) when is_binary(method) do # Convert camelCase to snake_case atom name = method |> Macro.underscore() |> String.to_atom() {name, error} end defp parse_failure(_), do: nil # Generate a stub function for a failed extraction @doc false @spec generate_failure_stub(atom(), String.t(), Spec.t()) :: Macro.t() defp generate_failure_stub(name, message, spec) do {params, auth} = method_metadata(name) {args, _param_map} = build_args_and_params(params, auth, []) spec_signature = Typespecs.generate_typespec_signature(name, params, auth, []) return_type_ast = Typespecs.ok_error_return_type_ast(name) exchange_id = spec.exchange_id || String.to_atom(spec.id) quote do @doc """ **Not supported on this exchange.** #{unquote(message)} This method exists in CCXT's capability list but cannot be called on this exchange class due to market type restrictions or inheritance limitations. """ @spec unquote(spec_signature) :: unquote(return_type_ast) def unquote(name)(unquote_splicing(args)) do # credo:disable-for-next-line Credo.Check.Design.AliasUsage {:error, CCXT.Error.not_supported( message: unquote(message), exchange: unquote(exchange_id) )} end end end @doc false # Generates stub functions for emulated methods that have no HTTP endpoint mapping. @spec generate_emulated_method_stubs(Spec.t()) :: [Macro.t()] defp generate_emulated_method_stubs(spec) do entries = Map.get(@emulated_methods, spec.id, []) excluded = build_excluded_names(spec) entries |> Enum.map(&emulated_entry_to_name_pair/1) |> Enum.reject(fn nil -> true {name, _entry} -> MapSet.member?(excluded, name) end) |> Map.new() |> Enum.map(fn {name, entry} -> generate_emulated_stub(name, entry, spec) end) end @doc false # Builds a set of method names that should not get emulated stubs. @dialyzer {:nowarn_function, build_excluded_names: 1} @spec build_excluded_names(Spec.t()) :: MapSet.t() defp build_excluded_names(spec) do endpoint_names = MapSet.new(spec.endpoints, & &1[:name]) failure_names = MapSet.new(failure_stub_names(spec)) MapSet.union(endpoint_names, failure_names) end @doc false # Converts an emulated entry to {name, entry} tuple or nil. @spec emulated_entry_to_name_pair(map()) :: {atom(), map()} | nil defp emulated_entry_to_name_pair(entry) do case emulated_entry_to_name(entry) do nil -> nil name -> {name, entry} end end @doc false # Extracts emulated method names from failure entries to avoid duplicate stubs. @spec failure_stub_names(Spec.t()) :: [atom()] defp failure_stub_names(spec) do failures = get_in(spec.endpoint_extraction_stats, ["failures"]) || [] failures |> Enum.map(&parse_failure/1) |> Enum.reject(&is_nil/1) |> Enum.map(fn {name, _msg} -> name end) end @doc false # Converts an emulated method entry to its snake_case atom name. @spec emulated_entry_to_name(map()) :: atom() | nil defp emulated_entry_to_name(%{"name" => name}) when is_binary(name) do name |> Macro.underscore() |> String.to_atom() end defp emulated_entry_to_name(_), do: nil @doc false # Generates a stub function for an emulated method. @spec generate_emulated_stub(atom(), map(), Spec.t()) :: Macro.t() defp generate_emulated_stub(name, entry, spec) do {params, auth} = method_metadata(name) {args, param_map} = build_args_and_params(params, auth, []) spec_signature = Typespecs.generate_typespec_signature(name, params, auth, []) return_type_ast = Typespecs.ok_error_return_type_ast(name) exchange_id = spec.exchange_id || String.to_atom(spec.id) reason_suffix = emulated_reason_suffix(entry) opts_var = {:opts, [], Elixir} creds_var = {:credentials, [], Elixir} quote do @doc """ **Emulated method (no HTTP call).** CCXT marks this method as emulated for this exchange#{unquote(reason_suffix)}. """ @spec unquote(spec_signature) :: unquote(return_type_ast) def unquote(name)(unquote_splicing(args)) do params = unquote(param_map) extra_params = Keyword.get(unquote(opts_var), :params, []) params = Map.merge(params, Map.new(extra_params)) credentials = unquote(if auth, do: creds_var) case CCXT.Emulation.dispatch(@ccxt_spec, unquote(name), :rest, %{ exchange_module: __MODULE__, params: params, opts: unquote(opts_var), credentials: credentials }) do :passthrough -> {:error, CCXT.Error.not_supported( message: "Emulated method not registered: #{unquote(Atom.to_string(name))}", exchange: unquote(exchange_id) )} {:ok, result} -> {:ok, result} {:error, _} = error -> error end end end end @doc false # Builds the reason suffix for emulated method docs. @spec emulated_reason_suffix(map()) :: String.t() defp emulated_reason_suffix(entry) do case Map.get(entry, "reasons", []) do [] -> "" reasons -> " (#{Enum.join(reasons, ", ")})" end end # Convert snake_case to camelCase for CCXT method lookup @doc false @spec camelize(String.t()) :: String.t() defp camelize(string) do string |> String.split("_") |> Enum.with_index() |> Enum.map_join(fn {word, 0} -> word {word, _} -> String.capitalize(word) end) end # Extracts method parameters and auth requirement from cached CCXT signatures. @doc false @spec method_metadata(atom()) :: {[atom()], boolean()} defp method_metadata(name) do camel_name = name |> Atom.to_string() |> camelize() params = @method_signatures |> Map.get(camel_name, []) |> Enum.map(&String.to_atom/1) auth = infer_auth_from_name(name) {params, auth} end # Infer authentication requirement from method name @doc false @spec infer_auth_from_name(atom()) :: boolean() defp infer_auth_from_name(name) do name_str = Atom.to_string(name) # Public methods typically start with fetch_ and are market data public_prefixes = [ "fetch_ticker", "fetch_order_book", "fetch_trades", "fetch_ohlcv", "fetch_markets", "fetch_currencies", "fetch_time", "fetch_status" ] not Enum.any?(public_prefixes, &String.starts_with?(name_str, &1)) end end