api-surface #1
2 changed files with 110 additions and 32 deletions
feat(snowflake): integer-only t(), cast/cast!/dump, is_snowflake guard, fixed to_unix
commit
2163ff5b2f
|
|
@ -2,18 +2,53 @@ defmodule Dexcord.Snowflake do
|
|||
@moduledoc """
|
||||
Discord snowflake id helpers, replacing `Nostrum.Snowflake`.
|
||||
|
||||
A snowflake is a 64-bit id whose top bits encode the creation time as
|
||||
milliseconds since the Discord epoch (2015-01-01). Ids are accepted either as
|
||||
integers or as the raw decimal strings Discord sends over the wire.
|
||||
A snowflake is a 64-bit unsigned integer encoding a creation timestamp.
|
||||
Dexcord represents snowflakes as plain integers everywhere; wire strings
|
||||
are normalized to integers once, at decode time, via `cast/1`.
|
||||
"""
|
||||
|
||||
import Bitwise
|
||||
|
||||
@discord_epoch_ms 1_420_070_400_000
|
||||
@timestamp_shift 22
|
||||
@max 0xFFFF_FFFF_FFFF_FFFF
|
||||
|
||||
@typedoc "A snowflake id, as an integer or a Discord decimal string."
|
||||
@type t :: integer() | String.t()
|
||||
@type t :: 0..0xFFFF_FFFF_FFFF_FFFF
|
||||
|
||||
@doc "Guard: `term` is an integer in the valid snowflake range."
|
||||
defguard is_snowflake(term) when is_integer(term) and term >= 0 and term <= @max
|
||||
|
||||
@doc """
|
||||
Casts a snowflake-ish value to an integer snowflake.
|
||||
|
||||
Accepts an integer in range, a decimal string, or a struct/map with an
|
||||
`:id` field holding an in-range integer.
|
||||
"""
|
||||
@spec cast(term()) :: {:ok, t()} | :error
|
||||
def cast(int) when is_snowflake(int), do: {:ok, int}
|
||||
|
||||
def cast(string) when is_binary(string) do
|
||||
case Integer.parse(string) do
|
||||
{int, ""} when is_snowflake(int) -> {:ok, int}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
def cast(%{id: id}) when is_snowflake(id), do: {:ok, id}
|
||||
def cast(_), do: :error
|
||||
|
||||
@doc "Like `cast/1` but raises `ArgumentError` on invalid input."
|
||||
@spec cast!(term()) :: t()
|
||||
def cast!(value) do
|
||||
case cast(value) do
|
||||
{:ok, int} -> int
|
||||
:error -> raise ArgumentError, "not a snowflake: #{inspect(value)}"
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Dumps a snowflake to its wire (string) representation."
|
||||
@spec dump(t()) :: String.t()
|
||||
def dump(snowflake) when is_snowflake(snowflake), do: Integer.to_string(snowflake)
|
||||
|
||||
@doc """
|
||||
Builds the smallest snowflake for a `DateTime` (timestamp bits only).
|
||||
|
|
@ -30,30 +65,15 @@ defmodule Dexcord.Snowflake do
|
|||
|
||||
def from_datetime(_), do: :error
|
||||
|
||||
@doc "The creation `DateTime` encoded in a snowflake, or `:error` if unparseable."
|
||||
@spec to_datetime(t()) :: {:ok, DateTime.t()} | :error
|
||||
def to_datetime(snowflake) do
|
||||
case to_integer(snowflake) do
|
||||
{:ok, int} -> DateTime.from_unix(to_unix(int), :millisecond)
|
||||
:error -> :error
|
||||
end
|
||||
end
|
||||
@doc "The creation `DateTime` encoded in a snowflake, or `:error` for non-snowflakes."
|
||||
@spec to_datetime(term()) :: {:ok, DateTime.t()} | :error
|
||||
def to_datetime(snowflake) when is_snowflake(snowflake),
|
||||
do: DateTime.from_unix(to_unix(snowflake), :millisecond)
|
||||
|
||||
def to_datetime(_), do: :error
|
||||
|
||||
@doc "The creation time of a snowflake as Unix milliseconds."
|
||||
@spec to_unix(t()) :: integer()
|
||||
def to_unix(snowflake) do
|
||||
{:ok, int} = to_integer(snowflake)
|
||||
(int >>> @timestamp_shift) + @discord_epoch_ms
|
||||
end
|
||||
|
||||
defp to_integer(int) when is_integer(int), do: {:ok, int}
|
||||
|
||||
defp to_integer(str) when is_binary(str) do
|
||||
case Integer.parse(str) do
|
||||
{int, ""} -> {:ok, int}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp to_integer(_), do: :error
|
||||
def to_unix(snowflake) when is_snowflake(snowflake),
|
||||
do: (snowflake >>> @timestamp_shift) + @discord_epoch_ms
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
defmodule Dexcord.SnowflakeTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
require Dexcord.Snowflake
|
||||
alias Dexcord.Snowflake
|
||||
|
||||
# Discord's documented example snowflake and its encoded creation time.
|
||||
|
|
@ -33,12 +34,69 @@ defmodule Dexcord.SnowflakeTest do
|
|||
assert Snowflake.from_datetime("nope") == :error
|
||||
end
|
||||
|
||||
test "string and integer inputs are equivalent" do
|
||||
assert Snowflake.to_unix(to_string(@vector)) == Snowflake.to_unix(@vector)
|
||||
assert Snowflake.to_datetime(to_string(@vector)) == Snowflake.to_datetime(@vector)
|
||||
describe "cast/1" do
|
||||
test "passes an in-range integer through" do
|
||||
assert Snowflake.cast(@vector) == {:ok, @vector}
|
||||
assert Snowflake.cast(0) == {:ok, 0}
|
||||
end
|
||||
|
||||
test "parses a decimal string" do
|
||||
assert Snowflake.cast(to_string(@vector)) == {:ok, @vector}
|
||||
end
|
||||
|
||||
test "rejects a string with trailing junk" do
|
||||
assert Snowflake.cast("123abc") == :error
|
||||
end
|
||||
|
||||
test "rejects a negative integer" do
|
||||
assert Snowflake.cast(-1) == :error
|
||||
end
|
||||
|
||||
test "rejects an out-of-range integer" do
|
||||
assert Snowflake.cast(0x1_0000_0000_0000_0000) == :error
|
||||
end
|
||||
|
||||
test "accepts a struct/map with an :id field" do
|
||||
assert Snowflake.cast(%{id: 123}) == {:ok, 123}
|
||||
end
|
||||
|
||||
test "rejects junk values" do
|
||||
assert Snowflake.cast(:atom) == :error
|
||||
assert Snowflake.cast(%{}) == :error
|
||||
assert Snowflake.cast("") == :error
|
||||
end
|
||||
end
|
||||
|
||||
test "to_datetime/1 returns :error for a non-numeric string" do
|
||||
describe "cast!/1" do
|
||||
test "returns the integer for valid input" do
|
||||
assert Snowflake.cast!(to_string(@vector)) == @vector
|
||||
end
|
||||
|
||||
test "raises ArgumentError with the offending value inspected" do
|
||||
assert_raise ArgumentError, ~r/not a snowflake: "123abc"/, fn ->
|
||||
Snowflake.cast!("123abc")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "dump/1" do
|
||||
test "produces the decimal string" do
|
||||
assert Snowflake.dump(@vector) == to_string(@vector)
|
||||
end
|
||||
end
|
||||
|
||||
describe "is_snowflake/1" do
|
||||
defp check(x) when Snowflake.is_snowflake(x), do: :snowflake
|
||||
defp check(_), do: :not_a_snowflake
|
||||
|
||||
test "is usable in a guard position" do
|
||||
assert check(123) == :snowflake
|
||||
assert check(-1) == :not_a_snowflake
|
||||
assert check("123") == :not_a_snowflake
|
||||
end
|
||||
end
|
||||
|
||||
test "to_datetime/1 returns :error for a junk string" do
|
||||
assert Snowflake.to_datetime("not-a-snowflake") == :error
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue