Packages

An Elixir client for the Polymarket API.

Current section

Files

Jump to
ex_polymarket lib schemas coerce.ex
Raw

lib/schemas/coerce.ex

defmodule Polymarket.Schemas.Coerce do
@moduledoc """
Helpers for building schema structs directly from a decoded message, without
the overhead of `Ecto.Changeset` casting.
Every value that arrives from an API is coerced to the schema's field type
here: numbers (`to_float/1`, `to_int/1`) — which the market feed delivers as
JSON *strings* (e.g. `"0.01"`, `"1782553314962"`) — timestamps
(`to_datetime/1`, `to_date/1`), JSON-encoded arrays (`to_string_list/1`,
`to_float_list/1`), and nested embeds (`embed/2`, `embed_list/2`). Anything
that does not parse cleanly becomes `nil` rather than raising, mirroring Ecto's
lenient cast semantics.
The numeric coercions and `require_fields/2` sit on the websocket hot path —
keep them branch-cheap and allocation-free.
"""
@doc """
Coerces a decoded JSON value to a `float`.
Accepts a float, an integer, or a fully-numeric binary. Returns `nil` for
`nil` or any value that does not parse cleanly.
"""
@spec to_float(term()) :: float() | nil
def to_float(nil), do: nil
def to_float(f) when is_float(f), do: f
def to_float(i) when is_integer(i), do: i * 1.0
def to_float(b) when is_binary(b) do
case Float.parse(b) do
{f, ""} -> f
_ -> nil
end
end
def to_float(_), do: nil
@doc """
Coerces a decoded JSON value to an `integer`.
Accepts an integer or a fully-numeric binary. Returns `nil` for `nil` or any
value that does not parse cleanly.
"""
@spec to_int(term()) :: integer() | nil
def to_int(nil), do: nil
def to_int(i) when is_integer(i), do: i
def to_int(b) when is_binary(b) do
case Integer.parse(b) do
{i, ""} -> i
_ -> nil
end
end
def to_int(_), do: nil
@doc """
Validates that each field in `fields` is present (non-`nil`) on `struct`.
Returns `{:ok, struct}` when all are present, otherwise
`{:error, {:missing_fields, missing}}`. This is the lightweight replacement for
`Ecto.Changeset.validate_required/2` on the hot path.
"""
@spec require_fields(struct(), [atom()]) ::
{:ok, struct()} | {:error, {:missing_fields, [atom()]}}
def require_fields(struct, fields) do
case for(field <- fields, is_nil(Map.fetch!(struct, field)), do: field) do
[] -> {:ok, struct}
missing -> {:error, {:missing_fields, missing}}
end
end
@doc """
Coerces a decoded JSON value to a UTC `DateTime`.
Accepts a `DateTime` or an ISO 8601 string (with or without an offset; a naive
string is read as UTC). Returns `nil` for `nil` or anything that does not parse.
"""
@spec to_datetime(term()) :: DateTime.t() | nil
def to_datetime(nil), do: nil
def to_datetime(%DateTime{} = datetime), do: datetime
def to_datetime(value) when is_binary(value) do
case DateTime.from_iso8601(value) do
{:ok, datetime, _offset} ->
datetime
{:error, :missing_offset} ->
case NaiveDateTime.from_iso8601(value) do
{:ok, naive} -> DateTime.from_naive!(naive, "Etc/UTC")
_ -> nil
end
_ ->
nil
end
end
def to_datetime(_), do: nil
@doc """
Coerces a decoded JSON value to a `Date`.
Accepts a `Date` or an ISO 8601 date string. Returns `nil` for `nil` or
anything that does not parse.
"""
@spec to_date(term()) :: Date.t() | nil
def to_date(nil), do: nil
def to_date(%Date{} = date), do: date
def to_date(value) when is_binary(value) do
case Date.from_iso8601(value) do
{:ok, date} -> date
_ -> nil
end
end
def to_date(_), do: nil
@doc """
Coerces a field that holds a list of strings.
Some Gamma fields arrive as a JSON-encoded *string* (e.g.
`"[\\"Up\\", \\"Down\\"]"`); those are decoded. An already-decoded list is
passed through, an empty string becomes `[]`, and `nil` passes through.
"""
@spec to_string_list(term()) :: [String.t()] | nil
def to_string_list(value), do: decode_list(value, & &1)
@doc """
Coerces a field that holds a list of floats, decoding a JSON-encoded string as
`to_string_list/1` does and coercing each element with `to_float/1`.
"""
@spec to_float_list(term()) :: [float()] | nil
def to_float_list(value), do: decode_list(value, fn list -> Enum.map(list, &to_float/1) end)
@spec decode_list(term(), ([term()] -> list())) :: list() | nil
defp decode_list(nil, _fun), do: nil
defp decode_list("", _fun), do: []
defp decode_list(list, fun) when is_list(list), do: fun.(list)
defp decode_list(value, fun) when is_binary(value) do
case Jason.decode(value) do
{:ok, list} when is_list(list) -> fun.(list)
_ -> nil
end
end
defp decode_list(_other, _fun), do: nil
@doc """
Builds one embedded struct from `attrs` via `module.from_attrs/1`, or `nil`
when `attrs` is absent or fails to build.
"""
@spec embed(module(), map() | nil) :: struct() | nil
def embed(_module, nil), do: nil
def embed(module, attrs) do
case module.from_attrs(attrs) do
{:ok, struct} -> struct
{:error, _reason} -> nil
end
end
@doc """
Builds a list of embedded structs via `module.from_attrs/1`, skipping any that
fail to build. `nil` becomes `[]`.
"""
@spec embed_list(module(), [map()] | nil) :: [struct()]
def embed_list(_module, nil), do: []
def embed_list(module, list) when is_list(list) do
Enum.flat_map(list, fn attrs ->
case embed(module, attrs) do
nil -> []
struct -> [struct]
end
end)
end
end