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() | nil} | {:rand_size, integer() | nil} | {:prefix, String.t() | nil} | {:delimiter, String.t() | nil} | {:compact_time, boolean() | nil} | {:monotonic, boolean() | [atom()] | nil} @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) monotonic = Keyword.get(opts, :monotonic) timestamp = Keyword.get(opts, :time, System.system_time(:millisecond)) %Codec{ case: case, compact_time: compact_time, monotonic: monotonic, 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) @doc """ Returns the global monotonic generation policy. When active, the random field is treated as a big-endian counter: the first ID in a millisecond is seeded from the CSPRNG and each subsequent same-ms ID advances it by a random positive step (per process, per prefix). This guarantees uniqueness and K-sortability within a burst while keeping consecutive IDs within a bounded window ahead — a mitigation, not cryptographic unpredictability — which is why it is off by default and opt-in per resource. Accepts `true`/`false`, or a list of sizes (alias-aware, e.g. `[:small]` matches both `:small` and `:s`). Overridable per-call/per-field with the `monotonic` option. See `UXID.Monotonic`. """ def monotonic(), do: Application.get_env(:uxid, :monotonic, false) @spec decode(String.t()) :: {:ok, Codec.t()} @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 # Only ever called on a binary (cast_strict/2 guards is_binary before calling), # so no non-binary clause is needed — the guard documents that contract. defp uuid_string?(term) when is_binary(term), do: Regex.match?(@uuid_format, term) # 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. Field options are threaded through to `generate!/1`, including `monotonic` for opt-in monotonic generation: field :id, UXID, autogenerate: true, prefix: "evt", size: :small, monotonic: true """ 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) monotonic = Map.get(opts, :monotonic) __MODULE__.generate!( case: case, prefix: prefix, size: size, rand_size: rand_size, delimiter: delimiter, compact_time: compact_time, monotonic: monotonic ) 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