defmodule CCXT.Spec do @moduledoc """ Compile-time JSON spec loader for exchange specifications. Reads exchange specs from `priv/specs/json/output/` and decodes them for use by the generator macro (`CCXT.Exchange`). Specs are loaded at compile time inside the generator — this module provides the loading functions but does not store specs as module attributes. ## Usage # In generator macro (compile time): spec = CCXT.Spec.load!("bybit") # List available exchanges: CCXT.Spec.exchanges() #=> ["aftermath", "alpaca", "apex", ...] ## Schema Version `load!/1` and `load_manifest!/0` enforce `schema_version` major `3` (ccxt_extract Schema 3.0.0+). Per-exchange specs must also carry a non-empty top-level `_provenance` map (RFC 6901 pointer keys → `"raw"` / `"derived"` / `"override"`); the manifest does not. A mismatch raises with a specific message naming the spec and the offending field, so a stale `priv/specs/json/output/` surfaces as a clear compile-time failure instead of a downstream `MatchError`. ## Dead Weight Stripping `load!/1` removes extraction-only data (`structure.interface_signatures` and `structure.methods`) that is only useful for static analysis, not runtime code generation. This reduces memory for `Macro.escape/1`. """ @spec_dir "priv/specs/json/output" # Only lowercase alphanumeric and underscore — rejects path traversal ("../mix") @valid_id_pattern ~r/^[a-z0-9_]+$/ # Keys removed from structure — extraction artifacts, not needed at runtime @stripped_keys ["interface_signatures", "methods"] # Accepted major version of the upstream spec schema. Bumped 1 → 2 for the # `_provenance` sidecar (ccxt_extract Task 61c/61d), then 2 → 3 when Task 117 # pruned `runtime.markets` (→ compact `runtime.symbols_index`) and dropped # `structure.parse_methods` + `structure.ws_methods` to fit the Hex cap. @schema_major 3 @doc """ Loads and decodes an exchange spec by ID. Reads the JSON file, decodes it, and strips extraction-only data from `structure`. Returns a map with string keys. Raises `File.Error` if the spec file doesn't exist, or `Jason.DecodeError` on invalid JSON. ## Examples spec = CCXT.Spec.load!("bybit") spec["exchange"]["id"] #=> "bybit" """ @spec load!(String.t()) :: map() def load!(exchange_id) when is_binary(exchange_id) do validate_id!(exchange_id) exchange_id |> spec_path() |> File.read!() |> Jason.decode!() |> validate_schema!(exchange_id) |> strip() end @doc """ Loads and decodes the manifest file. Returns the decoded `_manifest.json` containing exchange list, CCXT version, and extraction metadata. ## Examples manifest = CCXT.Spec.load_manifest!() manifest["ccxt_version"] #=> "4.5.46" manifest["exchange_count"] #=> 23 """ @spec load_manifest!() :: map() def load_manifest! do manifest_path() |> File.read!() |> Jason.decode!() |> validate_manifest_schema!() end @doc """ Returns the list of available exchange IDs from the manifest. ## Examples CCXT.Spec.exchanges() #=> ["aftermath", "alpaca", "apex", "arkham", ...] """ @spec exchanges() :: [String.t()] def exchanges do load_manifest!()["exchanges"] end @doc """ Returns the absolute path to a spec file for the given exchange ID. ## Examples CCXT.Spec.spec_path("bybit") #=> "/path/to/priv/specs/json/output/bybit.json" """ @spec spec_path(String.t()) :: String.t() def spec_path(exchange_id) when is_binary(exchange_id) do validate_id!(exchange_id) Path.join(spec_dir(), "#{exchange_id}.json") end @doc """ Returns the absolute path to the manifest file. ## Examples CCXT.Spec.manifest_path() #=> "/path/to/priv/specs/json/output/_manifest.json" """ @spec manifest_path() :: String.t() def manifest_path do Path.join(spec_dir(), "_manifest.json") end # Resolves the spec directory path. # Uses :code.priv_dir at runtime, falls back to compile-time path. defp spec_dir do case :code.priv_dir(:ccxt_client) do {:error, :bad_name} -> @spec_dir priv_dir -> Path.join(to_string(priv_dir), "specs/json/output") end end # Removes extraction-only keys from the structure section. # interface_signatures and methods are analysis artifacts from ccxt_extract, # not needed for runtime code generation. defp strip(%{"structure" => structure} = spec) do %{spec | "structure" => Map.drop(structure, @stripped_keys)} end defp strip(spec), do: spec @doc """ Validates a decoded per-exchange spec map against the Schema 3.0.0+ contract: `schema_version` must be a string whose numeric major equals `#{@schema_major}`, and `_provenance` must be a non-empty top-level map. On mismatch, raises with a message naming the exchange and the offending field. Returns the spec unchanged on success. Called internally from `load!/1`; exposed publicly so test suites can exercise every branch against in-memory maps without writing fixture files into the live spec directory. """ @spec validate_schema!(map(), String.t()) :: map() def validate_schema!(%{"schema_version" => v, "_provenance" => prov} = spec, exchange_id) when is_binary(v) and is_map(prov) and map_size(prov) > 0 do assert_major!(v, "spec #{inspect(exchange_id)}") spec end def validate_schema!(%{"schema_version" => v, "_provenance" => prov}, exchange_id) when is_binary(v) do raise "spec #{inspect(exchange_id)} has invalid _provenance: expected non-empty map, got #{inspect(prov)}" end def validate_schema!(%{"_provenance" => _}, exchange_id) do raise "spec #{inspect(exchange_id)} missing schema_version (expected major #{@schema_major})" end def validate_schema!(%{"schema_version" => v}, exchange_id) when is_binary(v) do assert_major!(v, "spec #{inspect(exchange_id)}") raise "spec #{inspect(exchange_id)} missing required _provenance map (schema 3.0.0+)" end def validate_schema!(_spec, exchange_id) do raise "spec #{inspect(exchange_id)} missing schema_version and/or _provenance" end @doc """ Validates a decoded manifest map against the Schema 3.0.0+ contract. Only the `schema_version` major is enforced — manifests have no `_provenance` by design (different document type from per-exchange specs). Raises on mismatch; returns the manifest unchanged on success. Exposed for test coverage of the guard branches. """ @spec validate_manifest_schema!(map()) :: map() def validate_manifest_schema!(%{"schema_version" => v} = manifest) when is_binary(v) do assert_major!(v, "manifest") manifest end def validate_manifest_schema!(_manifest) do raise "manifest missing schema_version (expected major #{@schema_major})" end # Accepts the configured @schema_major either alone (e.g. "3") or followed by # "." and anything ("3.0.1"). Rejects trailing garbage like "3malformed" — # Integer.parse("3malformed") returns {3, "malformed"}, so the raw major # check alone would otherwise pass. defp assert_major!(version, label) do case Integer.parse(version) do {@schema_major, ""} -> :ok {@schema_major, "." <> _} -> :ok _ -> raise "#{label} has unsupported schema_version #{inspect(version)} (expected major #{@schema_major})" end end # Validates exchange_id contains only safe characters (lowercase alphanumeric + underscore). # Rejects path traversal attempts like "../mix" or "foo/bar". defp validate_id!(exchange_id) do if !Regex.match?(@valid_id_pattern, exchange_id) do raise ArgumentError, "invalid exchange ID: #{inspect(exchange_id)} (must match #{inspect(@valid_id_pattern)})" end end end