lib/base62_uuid.ex

defmodule Base62UUID do
  @moduledoc """
  A base 62-encoded v4 UUID.
  """

  @length 22
  @uuid_length 32

  @doc """
  Generate a 22-byte base-62-encoded v4 UUID.
  """
  @spec generate :: String.t()
  def generate do
    encode!(UUID.uuid4())
  end

  @doc """
  Encode a v4 UUID to a 22-byte encoded UUID.

  Returns an error message if the UUID was invalid.

  # Example

      iex> Base62UUID.encode("063cd93e-dd59-43b6-928b-2d00a49087fc")
      {:ok, "0BllEZppLhVt2a9PljPUJ2"}
  """
  @spec encode(String.t()) :: {:ok, String.t()} | {:error, String.t()}
  def encode(uuid) do
    {:ok, encode!(uuid)}
  rescue
    e in ArgumentError -> {:error, e.message}
  end

  @doc """
  Encode a v4 UUID to a 22-byte encoded UUID, and raise on an error.

  # Example

      iex> Base62UUID.encode!("063cd93e-dd59-43b6-928b-2d00a49087fc")
      "0BllEZppLhVt2a9PljPUJ2"
  """
  @spec encode!(String.t()) :: String.t()
  def encode!(uuid) do
    uuid
    |> String.replace("-", "")
    |> String.to_integer(16)
    |> Base62.encode()
    |> ensure_length(@length)
  end

  @doc """
  Decode a an encoded UUID.

  Returns an error tuple if the UUID is invalid.

  # Example

      iex> Base62UUID.decode("0BllEZppLhVt2a9PljPUJ2")
      {:ok, "063cd93e-dd59-43b6-928b-2d00a49087fc"}
  """
  @spec decode(String.t()) :: {:ok, String.t()} | {:error, String.t()}
  def decode(uuid) do
    with {:ok, int} <- Base62.decode(uuid),
         raw_uuid = Integer.to_string(int, 16),
         raw_uuid = ensure_length(raw_uuid, @uuid_length),
         {:ok, uuid} <- make_uuid(raw_uuid) do
      {:ok, uuid}
    else
      _ -> {:error, "Invalid base-62 UUID"}
    end
  end

  @doc """
  Decode a an encoded UUID.

  # Example

      iex> Base62UUID.decode!("0BllEZppLhVt2a9PljPUJ2")
      "063cd93e-dd59-43b6-928b-2d00a49087fc"
  """
  @spec decode!(String.t()) :: String.t()
  def decode!(uuid) do
    case decode(uuid) do
      {:ok, uuid} -> uuid
      {:error, error} -> raise error
    end
  end

  @spec ensure_length(String.t(), pos_integer) :: String.t()
  defp ensure_length(encoded, length) when byte_size(encoded) == length,
    do: encoded

  defp ensure_length(encoded, length) do
    difference = length - String.length(encoded)
    String.duplicate("0", difference) <> encoded
  end

  @spec make_uuid(String.t()) :: {:ok, String.t()} | :error
  defp make_uuid(raw_uuid) do
    with <<gp_1::binary-size(8), gp_2::binary-size(4), gp_3::binary-size(4), gp_4::binary-size(4),
           gp_5::binary-size(12)>> <- String.downcase(raw_uuid) do
      {:ok, [gp_1, gp_2, gp_3, gp_4, gp_5] |> Enum.join("-")}
    else
      _ -> :error
    end
  end
end