diff --git a/lib/type_id.ex b/lib/type_id.ex index 23b051a..b4d7ea6 100644 --- a/lib/type_id.ex +++ b/lib/type_id.ex @@ -3,16 +3,125 @@ defmodule TypeID do Documentation for `TypeID`. """ - @doc """ - Hello world. + alias TypeID.Base32 - ## Examples + @enforce_keys [:prefix, :suffix] + defstruct @enforce_keys - iex> TypeID.hello() - :world + @opaque t() :: %__MODULE__{ + prefix: String.t(), + suffix: String.t() + } - """ - def hello do - :world + @spec new(prefix :: String.t()) :: t() + def new(prefix) do + suffix = + Uniq.UUID.uuid7(:raw) + |> Base32.encode() + + %__MODULE__{prefix: prefix, suffix: suffix} + end + + @spec type(tid :: t()) :: String.t() + def type(%__MODULE__{prefix: prefix}) do + prefix + end + + @spec suffix(tid :: t()) :: String.t() + def suffix(%__MODULE__{suffix: suffix}) do + suffix + end + + @spec to_string(tid :: t()) :: String.t() + def to_string(%__MODULE__{prefix: prefix, suffix: suffix}) do + prefix <> "_" <> suffix + end + + @spec uuid_bytes(tid :: t()) :: binary() + def uuid_bytes(%__MODULE__{suffix: suffix}) do + Base32.decode!(suffix) + end + + @spec uuid(tid :: t()) :: String.t() + def uuid(%__MODULE__{suffix: suffix}) do + suffix + |> Base32.decode!() + |> Uniq.UUID.to_string(:default) + end + + @spec from_string!(String.t()) :: t() | no_return() + def from_string!(str) do + [prefix, suffix] = String.split(str, "_") + from!(prefix, suffix) + end + + @spec from!(prefix :: String.t(), suffix :: String.t()) :: t() | no_return() + def from!(prefix, suffix) do + validate_prefix!(prefix) + validate_suffix!(suffix) + + %__MODULE__{prefix: prefix, suffix: suffix} + end + + @spec from(prefix :: String.t(), suffix :: String.t()) :: {:ok, t()} | :error + def from(prefix, suffix) do + {:ok, from!(prefix, suffix)} + rescue + ArgumentError -> :error + end + + @spec from_string!(String.t()) :: {:ok, t()} | :error + def from_string(str) do + {:ok, from_string!(str)} + rescue + ArgumentError -> :error + end + + @spec from_uuid!(prefix :: String.t(), uuid :: String.t()) :: t() | no_return() + def from_uuid!(prefix, uuid) do + {:ok, %Uniq.UUID{bytes: uuid_bytes, version: 7}} = Uniq.UUID.parse(uuid) + from_uuid_bytes!(prefix, uuid_bytes) + end + + @spec from_uuid_bytes!(prefix :: String.t(), uuid_bytes :: binary()) :: t() | no_return() + def from_uuid_bytes!(prefix, <>) do + suffix = Base32.encode(uuid_bytes) + from!(prefix, suffix) + end + + @spec from_uuid(prefix :: String.t(), uuid :: String.t()) :: {:ok, t()} | :error + def from_uuid(prefix, uuid) do + {:ok, from_uuid!(prefix, uuid)} + rescue + ArgumentError -> :error + end + + @spec from_uuid_bytes(prefix :: String.t(), uuid_bytes :: binary()) :: {:ok, t()} | :error + def from_uuid_bytes(prefix, uuid_bytes) do + {:ok, from_uuid_bytes!(prefix, uuid_bytes)} + rescue + ArgumentError -> :error + end + + defp validate_prefix!(prefix) do + unless prefix =~ ~r/[a-z]{0,63}/ do + raise ArgumentError, "invalid prefix: #{prefix}. prefix should match [a-z]{0,63}" + end + + :ok + end + + defp validate_suffix!(suffix) do + Base32.decode!(suffix) + + :ok + end +end + +defimpl Inspect, for: TypeID do + import Inspect.Algebra + + def inspect(tid, _opts) do + concat(["TypeID.from_string!(\"", tid.prefix, "_", tid.suffix, "\")"]) end end diff --git a/lib/type_id/base32.ex b/lib/type_id/base32.ex new file mode 100644 index 0000000..5196482 --- /dev/null +++ b/lib/type_id/base32.ex @@ -0,0 +1,210 @@ +defmodule TypeID.Base32 do + import Bitwise + + crockford_alphabet = ~c"0123456789abcdefghjkmnpqrstvwxyz" + + encoded = for e1 <- crockford_alphabet, e2 <- crockford_alphabet, do: bsl(e1, 8) + e2 + + to_decode_list = fn alphabet -> + alphabet = Enum.sort(alphabet) + map = Map.new(alphabet) + {min, _} = List.first(alphabet) + {max, _} = List.last(alphabet) + {min, Enum.map(min..max, &map[&1])} + end + + {min, decoded} = + crockford_alphabet + |> Enum.with_index() + |> to_decode_list.() + + @spec encode(binary()) :: binary() + def encode(data) when is_binary(data) do + do_encode(data, "") + end + + @spec decode(binary()) :: {:ok, binary()} | :error + def decode(string) when is_binary(string) do + {:ok, decode!(string)} + rescue + ArgumentError -> :error + end + + @spec decode!(binary()) :: binary() | no_return() + def decode!(string) when is_binary(string) do + do_decode!(string) + end + + @compile {:inline, [do_encode: 1]} + defp do_encode(byte) do + elem({unquote_splicing(encoded)}, byte) + end + + defp do_encode(<>, acc) do + do_encode( + rest, + << + acc::binary, + do_encode(c1)::16, + do_encode(c2)::16, + do_encode(c3)::16, + do_encode(c4)::16 + >> + ) + end + + defp do_encode(<>, acc) do + << + acc::binary, + do_encode(c1)::16, + do_encode(c2)::16, + do_encode(c3)::16, + c4 |> bsl(3) |> do_encode() |> band(0x00FF)::8 + >> + end + + defp do_encode(<>, acc) do + << + acc::binary, + do_encode(c1)::16, + do_encode(c2)::16, + c3 |> bsl(1) |> do_encode() |> band(0x00FF)::8 + >> + end + + defp do_encode(<>, acc) do + << + acc::binary, + do_encode(c1)::16, + c2 |> bsl(4) |> do_encode()::16 + >> + end + + defp do_encode(<>, acc) do + < bsl(2) |> do_encode()::16>> + end + + defp do_encode(<<>>, acc) do + acc + end + + defp do_decode!(<<>>), do: <<>> + + defp do_decode!(string) when is_binary(string) do + segs = div(byte_size(string) + 7, 8) - 1 + <> = string + + main = + for <>, into: <<>> do + << + do_decode!(c1)::5, + do_decode!(c2)::5, + do_decode!(c3)::5, + do_decode!(c4)::5, + do_decode!(c5)::5, + do_decode!(c6)::5, + do_decode!(c7)::5, + do_decode!(c8)::5 + >> + end + + case rest do + <> -> + <> + + <> -> + << + main::bits, + do_decode!(c1)::5, + do_decode!(c2)::5, + do_decode!(c3)::5, + bsr(do_decode!(c4), 4)::1 + >> + + <> -> + << + main::bits, + do_decode!(c1)::5, + do_decode!(c2)::5, + do_decode!(c3)::5, + do_decode!(c4)::5, + bsr(do_decode!(c5), 1)::4 + >> + + <> -> + << + main::bits, + do_decode!(c1)::5, + do_decode!(c2)::5, + do_decode!(c3)::5, + do_decode!(c4)::5, + do_decode!(c5)::5, + do_decode!(c6)::5, + bsr(do_decode!(c7), 3)::2 + >> + + <> -> + << + main::bits, + do_decode!(c1)::5, + do_decode!(c2)::5, + do_decode!(c3)::5, + do_decode!(c4)::5, + do_decode!(c5)::5, + do_decode!(c6)::5, + do_decode!(c7)::5, + do_decode!(c8)::5 + >> + + <> -> + <> + + <> -> + << + main::bits, + do_decode!(c1)::5, + do_decode!(c2)::5, + do_decode!(c3)::5, + bsr(do_decode!(c4), 4)::1 + >> + + <> -> + << + main::bits, + do_decode!(c1)::5, + do_decode!(c2)::5, + do_decode!(c3)::5, + do_decode!(c4)::5, + bsr(do_decode!(c5), 1)::4 + >> + + <> -> + << + main::bits, + do_decode!(c1)::5, + do_decode!(c2)::5, + do_decode!(c3)::5, + do_decode!(c4)::5, + do_decode!(c5)::5, + do_decode!(c6)::5, + bsr(do_decode!(c7), 3)::2 + >> + end + end + + defp do_decode!(char) do + try do + elem({unquote_splicing(decoded)}, char - unquote(min)) + rescue + _ -> bad_character!(char) + else + nil -> bad_character!(char) + char -> char + end + end + + defp bad_character!(byte) do + raise ArgumentError, + "non-alphabet character found: #{inspect(<>, binaries: :as_strings)} (byte #{byte})" + end +end diff --git a/mix.exs b/mix.exs index 369e9da..76ea57c 100644 --- a/mix.exs +++ b/mix.exs @@ -21,8 +21,7 @@ defmodule TypeID.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - # {:dep_from_hexpm, "~> 0.3.0"}, - # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + {:uniq, "~> 0.5.4"} ] end end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..18a3dfc --- /dev/null +++ b/mix.lock @@ -0,0 +1,3 @@ +%{ + "uniq": {:hex, :uniq, "0.5.4", "0602d0c75682f14863c1edea48920bd3d278b29ff7801d56e040e5f9f51ae0e2", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "856ec690d713f97373a3746afada340143c7e37a6488fca5afa720bfb08f0fe5"}, +} diff --git a/test/type_id_test.exs b/test/type_id_test.exs index 0404774..8101f10 100644 --- a/test/type_id_test.exs +++ b/test/type_id_test.exs @@ -1,8 +1,11 @@ defmodule TypeIDTest do use ExUnit.Case - doctest TypeID - test "greets the world" do - assert TypeID.hello() == :world + describe "new/1" do + test "returns a new TypeID struct" do + tid = TypeID.new("test") + assert is_struct(tid, TypeID) + end end + end