Packages

Elixir client library for cryptocurrency exchanges — generated from CCXT specs via compile-time macros.

Current section

Files

Jump to
ccxt_client lib ccxt spec.ex
Raw

lib/ccxt/spec.ex

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