Skip to main content

lib/ex_cedar/entity_uid.ex

defmodule ExCedar.EntityUid do
  @moduledoc """
  Cedar entity UID — a `{type, id}` pair that uniquely identifies an entity.

  The `type` field may be namespaced (e.g. `"App::User"`). The `id` is the
  bare string value (unescaped). Use `to_string/1` to render the Cedar syntax
  `Type::"id"` and `parse/1` to parse it back.
  """

  defstruct [:type, :id]

  @type t :: %__MODULE__{type: String.t(), id: String.t()}

  # Matches type (greedy, so last :: before quoted id wins) then ::"id"
  @uid_pattern ~r/^(.+)::"((?:[^"\\]|\\.)*)"$/

  @doc "Builds an `EntityUid` from `type` and `id` strings."
  def new(type, id), do: %__MODULE__{type: type, id: id}

  @doc """
  Parses a Cedar-syntax entity UID string into an `EntityUid`.

      iex> ExCedar.EntityUid.parse(~s|User::"alice"|)
      {:ok, %ExCedar.EntityUid{type: "User", id: "alice"}}

      iex> {:ok, uid} = ExCedar.EntityUid.parse(~s|App::User::"bob"|)
      iex> uid.type
      "App::User"

  Returns `{:error, %ExCedar.Error.Request{}}` if the string is not a valid
  Cedar entity UID.
  """
  def parse(string) do
    case Regex.run(@uid_pattern, string, capture: :all_but_first) do
      [type, raw_id] ->
        {:ok, %__MODULE__{type: type, id: unescape(raw_id)}}

      nil ->
        {:error, %ExCedar.Error.Request{message: "invalid entity uid: #{inspect(string)}"}}
    end
  end

  @doc """
  Renders the `EntityUid` as a Cedar-syntax string (`Type::"id"`).

  Special characters in `id` (backslash and double-quote) are escaped.
  """
  def to_string(%__MODULE__{type: type, id: id}) do
    type <> "::\"" <> escape(id) <> "\""
  end

  @doc """
  Returns the Cedar JSON entity-reference shape: `%{"type" => type, "id" => id}`.
  """
  def to_json(%__MODULE__{type: type, id: id}), do: %{"type" => type, "id" => id}

  defp escape(id) do
    id
    |> String.replace("\\", "\\\\")
    |> String.replace("\"", "\\\"")
  end

  defp unescape(raw) do
    Regex.replace(~r/\\(["\\])/, raw, "\\1")
  end
end