Current section
Files
Jump to
Current section
Files
lib/uxid.ex
defmodule UXID do
@moduledoc """
Generates UXIDs and acts as an Ecto ParameterizedType
**U**ser e**X**perience focused **ID**entifiers (UXIDs) are identifiers which:
* Describe the resource (aid in debugging and investigation)
* Work well with copy and paste (double clicking selects the entire ID)
* Can be shortened for low cardinality resources
* Are secure against enumeration attacks
* Can be generated by application code (not tied to the datastore)
* Are K-sortable (lexicographically sortable by time - works well with datastore indexing)
* Do not require any coordination (human or automated) at startup, or generation
* Are very unlikely to collide (more likely with less randomness)
* Are easily and accurately transmitted to another human using a telephone
Many of the concepts of Stripe IDs have been used in this library.
"""
@typedoc "Options for generating a UXID"
@type option ::
{:case, atom()}
| {:time, integer()}
| {:size, atom()}
| {:rand_size, integer()}
| {:prefix, String.t()}
| {:delimiter, String.t()}
@type options :: [option()]
@typedoc "A UXID represented as a String"
@type t() :: String.t()
alias UXID.{Codec, Encoder}
alias UXID.Decoder
# Crockford Base32 alphabet (excludes I, L, O, U), in both cases. A UXID body
# is made up entirely of these characters.
@crockford_body ~r/\A[0-9ABCDEFGHJKMNPQRSTVWXYZabcdefghjkmnpqrstvwxyz]+\z/
# Canonical 36-character hyphenated UUID, any case. Identifiers that predate
# UXID adoption are stored as UUID strings and must remain castable.
@uuid_format ~r/\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/
# Shortest legitimate encoded body: a compact 40-bit timestamp with no
# randomness encodes to 8 characters.
@min_body_length 8
@spec generate(opts :: options()) :: {:ok, __MODULE__.t()}
@doc """
Returns an encoded UXID string along with response status.
"""
def generate(opts \\ []) do
{:ok, %Codec{string: string}} = new(opts)
{:ok, string}
end
@spec generate!(opts :: options()) :: __MODULE__.t()
@doc """
Returns an unwrapped encoded UXID string.
"""
def generate!(opts \\ []) do
{:ok, uxid} = generate(opts)
uxid
end
@spec new(opts :: options()) :: {:ok, Codec.t()}
@doc """
Returns a new UXID.Codec struct. This is useful for development.
"""
def new(opts \\ []) do
case = Keyword.get(opts, :case, encode_case())
prefix = Keyword.get(opts, :prefix)
rand_size = Keyword.get(opts, :rand_size)
size = Keyword.get(opts, :size)
delimiter = Keyword.get(opts, :delimiter)
compact_time = Keyword.get(opts, :compact_time)
timestamp = Keyword.get(opts, :time, System.system_time(:millisecond))
%Codec{
case: case,
compact_time: compact_time,
prefix: prefix,
rand_size: rand_size,
size: size,
time: timestamp,
delimiter: delimiter
}
|> Encoder.process()
end
def encode_case(), do: Application.get_env(:uxid, :case, :lower)
@doc """
Returns the delimiter used to separate a prefix from the encoded body.
Defaults to `"_"` and can be overridden globally with the `:delimiter`
application env, or per-call/per-field with the `:delimiter` option.
"""
def default_delimiter(), do: Application.get_env(:uxid, :delimiter, "_")
@doc """
Returns the minimum size configuration.
When set, any requested size smaller than this will be upgraded.
"""
def min_size(), do: Application.get_env(:uxid, :min_size, nil)
@doc """
Returns whether compact time encoding is enabled for small sizes.
When true, :xs/:xsmall and :s/:small use 40 bits for timestamp instead of 48,
adding 8 bits to randomness. This is a global policy that can be overridden
per-call with the `compact_time` option.
This provides perfect 5-bit Crockford Base32 alignment (8 chars vs 10 chars).
K-sortability is maintained until ~September 2039.
"""
def compact_small_times(), do: Application.get_env(:uxid, :compact_small_times, false)
@spec decode(String.t()) :: {:ok, %Codec{}}
@doc """
Decodes a UXID string and returns a Codec struct with extracted components.
"""
def decode(uxid) do
%Codec{
string: uxid
}
|> Decoder.process()
end
@spec valid?(term(), keyword()) :: boolean()
@doc """
Returns `true` if the given value is a structurally valid UXID.
A valid UXID is a binary made up of an optional prefix, the delimiter, and a
Crockford Base32 body of at least #{@min_body_length} characters. This checks
*structure*, not authenticity: it cannot distinguish a generated UXID from an
arbitrary Base32 string of the same shape, and it deliberately does **not**
accept a bare UUID (see `cast/2`'s `:validate` mode for UUID coexistence).
## Options
* `:prefix` - when present, the value must carry exactly this prefix. When
omitted, any prefix (or none) is accepted.
* `:delimiter` - the prefix delimiter (defaults to the configured
delimiter, see `default_delimiter/0`).
## Examples
iex> UXID.valid?("cus_01emdgjf0dqxqj8fm78xe97y3h")
true
iex> UXID.valid?("cus_01emdgjf0dqxqj8fm78xe97y3h", prefix: "cus")
true
iex> UXID.valid?("cus_01emdgjf0dqxqj8fm78xe97y3h", prefix: "usr")
false
iex> UXID.valid?("nope!")
false
"""
def valid?(term, opts \\ [])
def valid?(term, opts) when is_binary(term) do
delimiter = Keyword.get(opts, :delimiter, default_delimiter())
{prefix, body} = split_prefix(term, delimiter)
body_valid?(body) and prefix_ok?(prefix, Keyword.get(opts, :prefix))
end
def valid?(_term, _opts), do: false
defp body_valid?(body) do
String.length(body) >= @min_body_length and Regex.match?(@crockford_body, body)
end
defp prefix_ok?(_actual, nil), do: true
defp prefix_ok?(actual, expected), do: actual == expected
defp split_prefix(string, delimiter) do
case String.split(string, delimiter) do
[body] ->
{nil, body}
parts ->
{body, prefix_parts} = List.pop_at(parts, -1)
{Enum.join(prefix_parts, delimiter), body}
end
end
defp uuid_string?(term) when is_binary(term), do: Regex.match?(@uuid_format, term)
defp uuid_string?(_term), do: false
# Define additional functions for custom Ecto type if Ecto is loaded
if Code.ensure_loaded?(Ecto.ParameterizedType) do
@behaviour Ecto.ParameterizedType
@doc """
Generates a loaded version of the UXID.
"""
def autogenerate(opts) do
case = Map.get(opts, :case, encode_case())
prefix = Map.get(opts, :prefix)
size = Map.get(opts, :size)
rand_size = Map.get(opts, :rand_size)
delimiter = Map.get(opts, :delimiter)
compact_time = Map.get(opts, :compact_time)
__MODULE__.generate!(
case: case,
prefix: prefix,
size: size,
rand_size: rand_size,
delimiter: delimiter,
compact_time: compact_time
)
end
@doc """
Returns the underlying schema type for a UXID.
"""
def type(_opts), do: :string
@doc """
Converts the options specified in the field macro into parameters to be used in other callbacks.
"""
def init(opts) do
# validate_opts(opts)
Enum.into(opts, %{})
end
@doc """
Casts the given input to the UXID ParameterizedType with the given parameters.
By default any binary is accepted unchanged (backwards compatible). When the
field opts in with `validate: true`, the value must be either a structurally
valid UXID carrying the field's configured `:prefix` (see `valid?/2`) or a
legacy bare UUID string; anything else casts to `:error`. UUID coexistence
can be turned off with `allow_uuid: false`.
field :owner_org_id, UXID, prefix: "org", validate: true
"""
def cast(data, params) do
if Map.get(params, :validate, false) do
cast_strict(data, params)
else
cast_binary(data)
end
end
defp cast_binary(nil), do: {:ok, nil}
defp cast_binary(term) when is_binary(term), do: {:ok, term}
defp cast_binary(_), do: :error
defp cast_strict(nil, _params), do: {:ok, nil}
defp cast_strict(term, params) when is_binary(term) do
opts = [prefix: Map.get(params, :prefix), delimiter: Map.get(params, :delimiter, default_delimiter())]
cond do
valid?(term, opts) -> {:ok, term}
Map.get(params, :allow_uuid, true) and uuid_string?(term) -> {:ok, term}
true -> :error
end
end
defp cast_strict(_term, _params), do: :error
@doc """
Loads the given term into a UXID.
"""
def load(data, _loader, _params), do: {:ok, data}
@doc """
Dumps the given term into an Ecto native type.
"""
def dump(data, _dumper, _params), do: {:ok, data}
@doc """
Dictates how the type should be treated if embedded.
For UXIDs, we use :self since they're already strings.
"""
def embed_as(_format, _params), do: :self
@doc """
Checks if two UXIDs are equal.
"""
def equal?(left, right, _params), do: left == right
end
end