Current section
Files
Jump to
Current section
Files
lib/aether/atproto/at_uri.ex
defmodule Aether.ATProto.AtUri do
@moduledoc """
AT URI (at://) parsing and validation for ATProto.
AT URIs are used to reference records and repositories in the AT Protocol.
They can reference a repository (using just authority), a collection within
a repository, or a specific record.
## Format
at://AUTHORITY[/COLLECTION[/RKEY]][#FRAGMENT]
Where:
- `AUTHORITY` - A DID or handle identifying the repository
- `COLLECTION` - An NSID identifying the record collection (e.g., `app.bsky.feed.post`)
- `RKEY` - A record key identifying a specific record
- `FRAGMENT` - Optional fragment identifier
## Examples
# Repository reference (DID)
iex> Aether.ATProto.AtUri.parse_at_uri("at://did:plc:z72i7hdynmk24r6zlsdc6nxd")
{:ok, %Aether.ATProto.AtUri{authority: "did:plc:z72i7hdynmk24r6zlsdc6nxd"}}
# Repository reference (handle)
iex> Aether.ATProto.AtUri.parse_at_uri("at://alice.bsky.social")
{:ok, %Aether.ATProto.AtUri{authority: "alice.bsky.social"}}
# Collection reference
iex> Aether.ATProto.AtUri.parse_at_uri("at://alice.bsky.social/app.bsky.feed.post")
{:ok, %Aether.ATProto.AtUri{authority: "alice.bsky.social", collection: "app.bsky.feed.post"}}
# Record reference
iex> Aether.ATProto.AtUri.parse_at_uri("at://did:plc:z72i7hdynmk24r6zlsdc6nxd/app.bsky.feed.post/3jwdwj2ctlk26")
{:ok, %Aether.ATProto.AtUri{authority: "did:plc:z72i7hdynmk24r6zlsdc6nxd", collection: "app.bsky.feed.post", rkey: "3jwdwj2ctlk26"}}
# With fragment
iex> Aether.ATProto.AtUri.parse_at_uri("at://alice.bsky.social/app.bsky.feed.post/123#anchor")
{:ok, %Aether.ATProto.AtUri{authority: "alice.bsky.social", collection: "app.bsky.feed.post", rkey: "123", fragment: "anchor"}}
"""
alias Aether.ATProto.DID
defstruct [:authority, :collection, :rkey, :fragment]
@type t :: %__MODULE__{
authority: String.t(),
collection: String.t() | nil,
rkey: String.t() | nil,
fragment: String.t() | nil
}
defmodule ParseError do
defexception message: "Invalid AT URI format"
end
# Maximum AT URI length (8 kilobytes)
@max_length 8192
# NSID pattern: reverse domain notation (e.g., com.example.foo)
@nsid_pattern ~r/^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/
# Record key pattern: alphanumeric and some special characters
@rkey_pattern ~r/^[a-zA-Z0-9._~:@!$&'\(\)*+,;=%\-]+$/
@doc """
Parse an AT URI string into structured data.
Returns `{:ok, %Aether.ATProto.AtUri{}}` on success or `{:error, reason}` on failure.
## Examples
iex> Aether.ATProto.AtUri.parse_at_uri("at://did:plc:z72i7hdynmk24r6zlsdc6nxd")
{:ok, %Aether.ATProto.AtUri{authority: "did:plc:z72i7hdynmk24r6zlsdc6nxd"}}
iex> Aether.ATProto.AtUri.parse_at_uri("at://alice.example.com/app.bsky.feed.post/123")
{:ok, %Aether.ATProto.AtUri{authority: "alice.example.com", collection: "app.bsky.feed.post", rkey: "123"}}
iex> Aether.ATProto.AtUri.parse_at_uri("invalid")
{:error, :invalid_format}
iex> Aether.ATProto.AtUri.parse_at_uri("at://")
{:error, :missing_authority}
"""
@spec parse_at_uri(String.t()) :: {:ok, t()} | {:error, atom()}
def parse_at_uri("at://" <> rest) when is_binary(rest) do
with :ok <- validate_length("at://" <> rest),
{rest_without_fragment, fragment} <- extract_fragment(rest),
{:ok, authority, collection, rkey} <- parse_path_components(rest_without_fragment) do
{:ok,
%__MODULE__{authority: authority, collection: collection, rkey: rkey, fragment: fragment}}
end
end
def parse_at_uri(_invalid), do: {:error, :invalid_format}
@doc """
Parse an AT URI string, raising an exception on error.
## Examples
iex> Aether.ATProto.AtUri.parse_at_uri!("at://alice.bsky.social")
%Aether.ATProto.AtUri{authority: "alice.bsky.social"}
iex> Aether.ATProto.AtUri.parse_at_uri!("invalid")
** (Aether.ATProto.AtUri.ParseError) Invalid AT URI: invalid_format
"""
@spec parse_at_uri!(String.t()) :: t()
def parse_at_uri!(at_uri_string) when is_binary(at_uri_string) do
case parse_at_uri(at_uri_string) do
{:ok, at_uri} -> at_uri
{:error, reason} -> raise ParseError, "Invalid AT URI: #{reason}"
end
end
def parse_at_uri!(%__MODULE__{} = at_uri), do: at_uri
def parse_at_uri!(_other), do: raise(ParseError, "Invalid AT URI: invalid_format")
@doc """
Check if a value is a valid AT URI.
## Examples
iex> Aether.ATProto.AtUri.valid_at_uri?("at://alice.bsky.social")
true
iex> Aether.ATProto.AtUri.valid_at_uri?("invalid")
false
iex> Aether.ATProto.AtUri.valid_at_uri?(%Aether.ATProto.AtUri{authority: "alice.bsky.social"})
true
"""
@spec valid_at_uri?(String.t() | t()) :: boolean()
def valid_at_uri?(at_uri_string) when is_binary(at_uri_string) do
match?({:ok, _}, parse_at_uri(at_uri_string))
end
def valid_at_uri?(%__MODULE__{}), do: true
def valid_at_uri?(_), do: false
@doc """
Convert an AT URI struct back to its string representation.
## Examples
iex> at_uri = %Aether.ATProto.AtUri{authority: "alice.bsky.social"}
iex> Aether.ATProto.AtUri.at_uri_to_string(at_uri)
"at://alice.bsky.social"
iex> at_uri = %Aether.ATProto.AtUri{authority: "alice.bsky.social", collection: "app.bsky.feed.post", rkey: "123"}
iex> Aether.ATProto.AtUri.at_uri_to_string(at_uri)
"at://alice.bsky.social/app.bsky.feed.post/123"
iex> at_uri = %Aether.ATProto.AtUri{authority: "alice.bsky.social", fragment: "anchor"}
iex> Aether.ATProto.AtUri.at_uri_to_string(at_uri)
"at://alice.bsky.social#anchor"
"""
@spec at_uri_to_string(t() | String.t()) :: String.t()
def at_uri_to_string(%__MODULE__{
authority: authority,
collection: collection,
rkey: rkey,
fragment: fragment
}) do
["at://", authority]
|> append_collection(collection)
|> append_rkey(rkey)
|> Enum.join()
|> append_fragment(fragment)
end
def at_uri_to_string(at_uri_string) when is_binary(at_uri_string), do: at_uri_string
@doc """
Extract the authority from an AT URI.
## Examples
iex> Aether.ATProto.AtUri.authority("at://alice.bsky.social/app.bsky.feed.post/123")
"alice.bsky.social"
iex> Aether.ATProto.AtUri.authority(%Aether.ATProto.AtUri{authority: "did:plc:z72i7hdynmk24r6zlsdc6nxd"})
"did:plc:z72i7hdynmk24r6zlsdc6nxd"
iex> Aether.ATProto.AtUri.authority("invalid")
{:error, :invalid_at_uri}
"""
@spec authority(String.t() | t()) :: String.t() | {:error, :invalid_at_uri}
def authority(%__MODULE__{authority: authority}), do: authority
def authority(at_uri_string) when is_binary(at_uri_string) do
case parse_at_uri(at_uri_string) do
{:ok, %__MODULE__{authority: authority}} -> authority
{:error, _} -> {:error, :invalid_at_uri}
end
end
@doc """
Extract the collection from an AT URI.
## Examples
iex> Aether.ATProto.AtUri.collection("at://alice.bsky.social/app.bsky.feed.post/123")
"app.bsky.feed.post"
iex> Aether.ATProto.AtUri.collection("at://alice.bsky.social")
nil
iex> Aether.ATProto.AtUri.collection(%Aether.ATProto.AtUri{authority: "alice.bsky.social", collection: "app.bsky.feed.post"})
"app.bsky.feed.post"
"""
@spec collection(String.t() | t()) :: String.t() | nil | {:error, :invalid_at_uri}
def collection(%__MODULE__{collection: collection}), do: collection
def collection(at_uri_string) when is_binary(at_uri_string) do
case parse_at_uri(at_uri_string) do
{:ok, %__MODULE__{collection: collection}} -> collection
{:error, _} -> {:error, :invalid_at_uri}
end
end
@doc """
Extract the record key from an AT URI.
## Examples
iex> Aether.ATProto.AtUri.rkey("at://alice.bsky.social/app.bsky.feed.post/123")
"123"
iex> Aether.ATProto.AtUri.rkey("at://alice.bsky.social/app.bsky.feed.post")
nil
iex> Aether.ATProto.AtUri.rkey(%Aether.ATProto.AtUri{authority: "alice.bsky.social", collection: "app.bsky.feed.post", rkey: "abc123"})
"abc123"
"""
@spec rkey(String.t() | t()) :: String.t() | nil | {:error, :invalid_at_uri}
def rkey(%__MODULE__{rkey: rkey}), do: rkey
def rkey(at_uri_string) when is_binary(at_uri_string) do
case parse_at_uri(at_uri_string) do
{:ok, %__MODULE__{rkey: rkey}} -> rkey
{:error, _} -> {:error, :invalid_at_uri}
end
end
@doc """
Extract the fragment from an AT URI.
## Examples
iex> Aether.ATProto.AtUri.fragment("at://alice.bsky.social#anchor")
"anchor"
iex> Aether.ATProto.AtUri.fragment("at://alice.bsky.social")
nil
iex> Aether.ATProto.AtUri.fragment(%Aether.ATProto.AtUri{authority: "alice.bsky.social", fragment: "section1"})
"section1"
"""
@spec fragment(String.t() | t()) :: String.t() | nil | {:error, :invalid_at_uri}
def fragment(%__MODULE__{fragment: fragment}), do: fragment
def fragment(at_uri_string) when is_binary(at_uri_string) do
case parse_at_uri(at_uri_string) do
{:ok, %__MODULE__{fragment: fragment}} -> fragment
{:error, _} -> {:error, :invalid_at_uri}
end
end
# Private functions
defp validate_length(uri) when byte_size(uri) > @max_length, do: {:error, :uri_too_long}
defp validate_length(_uri), do: :ok
defp extract_fragment(string) do
case String.split(string, "#", parts: 2) do
[rest, frag] -> {rest, frag}
[rest] -> {rest, nil}
end
end
defp parse_path_components(string) do
case String.split(string, "/", parts: 4) do
[""] -> {:error, :missing_authority}
[authority] -> validate_authority(authority, nil, nil)
[authority, collection] -> validate_components(authority, collection, nil)
[authority, collection, rkey] -> validate_components(authority, collection, rkey)
_ -> {:error, :invalid_format}
end
end
defp validate_components(authority, collection, rkey) do
with :ok <- validate_authority_string(authority),
:ok <- validate_collection(collection),
:ok <- validate_rkey(rkey) do
{:ok, authority, collection, rkey}
end
end
defp validate_authority(authority, collection, rkey) do
case validate_authority_string(authority) do
:ok -> {:ok, authority, collection, rkey}
error -> error
end
end
defp validate_authority_string(""), do: {:error, :missing_authority}
defp validate_authority_string(authority) do
cond do
String.starts_with?(authority, "did:") -> validate_did_authority(authority)
true -> validate_handle_authority(authority)
end
end
defp validate_did_authority(did) do
case DID.parse_did(did) do
{:ok, _} -> :ok
{:error, _} -> {:error, :invalid_did}
end
end
defp validate_handle_authority(handle) do
# Handle validation: domain-like format
# Allow alphanumeric, dots, hyphens
if String.match?(
handle,
~r/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
) do
:ok
else
{:error, :invalid_handle}
end
end
defp validate_collection(collection) do
if String.match?(collection, @nsid_pattern) do
:ok
else
{:error, :invalid_collection}
end
end
defp validate_rkey(nil), do: :ok
defp validate_rkey(rkey) do
if String.match?(rkey, @rkey_pattern) and byte_size(rkey) <= 512 do
:ok
else
{:error, :invalid_rkey}
end
end
defp append_collection(parts, nil), do: parts
defp append_collection(parts, collection), do: parts ++ ["/", collection]
defp append_rkey(parts, nil), do: parts
defp append_rkey(parts, rkey), do: parts ++ ["/", rkey]
defp append_fragment(string, nil), do: string
defp append_fragment(string, fragment), do: string <> "#" <> fragment
end