Packages

Aether ATProto is a set of common & shared logic to implement the AT Protocol

Current section

Files

Jump to
aether_atproto lib aether atproto record.ex
Raw

lib/aether/atproto/record.ex

defmodule Aether.ATProto.Record do
@moduledoc """
ATProto record data structure.
Records in ATProto are data objects with a type (defined by a Lexicon schema)
and arbitrary data fields. This module provides a simple struct wrapper and
transformation functions for working with records.
## Usage
Create records directly using struct syntax:
%Aether.ATProto.Record{
type: "app.bsky.feed.post",
data: %{"text" => "Hello, ATProto!"}
}
Parse from external sources (XRPC responses):
Aether.ATProto.Record.from_map(%{
"$type" => "app.bsky.feed.post",
"text" => "Hello, ATProto!",
"createdAt" => "2024-01-15T12:00:00Z"
})
Convert to map for XRPC requests:
record = %Aether.ATProto.Record{type: "app.bsky.feed.post", data: %{"text" => "Hi"}}
Aether.ATProto.Record.to_map(record)
#=> %{"$type" => "app.bsky.feed.post", "text" => "Hi"}
## Pattern Matching
Use Elixir's pattern matching for working with records:
# Extract type and data
%Aether.ATProto.Record{type: type, data: data} = record
# Update data immutably
updated = %{record | data: Map.put(record.data, "text", "Updated")}
# Match on specific types
def handle_record(%Aether.ATProto.Record{type: "app.bsky.feed.post"} = post) do
# Handle post
end
## Record Structure
- `type` - The Lexicon schema NSID (e.g., "app.bsky.feed.post")
- `data` - Map containing the record's data fields
- `cid` - Optional Content Identifier if loaded from repository
"""
defstruct [:type, :data, :cid]
@type t :: %__MODULE__{
type: String.t(),
data: map(),
cid: String.t() | nil
}
# NSID pattern for type validation (reverse domain notation, lowercase only)
@nsid_pattern ~r/^[a-z]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z]([a-z0-9-]{0,61}[a-z0-9])?)+\.[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/
@doc """
Transform a map with `$type` field into a Record struct.
Useful for parsing records from XRPC responses or JSON.
## Examples
iex> Aether.ATProto.Record.from_map(%{
...> "$type" => "app.bsky.feed.post",
...> "text" => "Hello",
...> "createdAt" => "2024-01-15T12:00:00Z"
...> })
{:ok, %Aether.ATProto.Record{
type: "app.bsky.feed.post",
data: %{"text" => "Hello", "createdAt" => "2024-01-15T12:00:00Z"}
}}
iex> Aether.ATProto.Record.from_map(%{"text" => "Missing type"})
{:error, :missing_type}
iex> Aether.ATProto.Record.from_map(%{"$type" => "InvalidType"})
{:error, :invalid_type}
"""
@spec from_map(map()) :: {:ok, t()} | {:error, :missing_type | :invalid_type}
def from_map(%{"$type" => type} = map) when is_binary(type) do
if valid_type?(type) do
# Extract CID if present
cid = Map.get(map, "$cid") || Map.get(map, "cid")
# Remove protocol fields from data
data =
map
|> Map.delete("$type")
|> Map.delete("$cid")
|> Map.delete("cid")
{:ok, %__MODULE__{type: type, data: data, cid: cid}}
else
{:error, :invalid_type}
end
end
def from_map(_map), do: {:error, :missing_type}
@doc """
Transform a Record struct into a map with `$type` field.
Useful for preparing records for XRPC requests or JSON serialization.
## Examples
iex> record = %Aether.ATProto.Record{
...> type: "app.bsky.feed.post",
...> data: %{"text" => "Hello", "createdAt" => "2024-01-15T12:00:00Z"}
...> }
iex> Aether.ATProto.Record.to_map(record)
%{
"$type" => "app.bsky.feed.post",
"text" => "Hello",
"createdAt" => "2024-01-15T12:00:00Z"
}
iex> record = %Aether.ATProto.Record{
...> type: "app.bsky.feed.post",
...> data: %{"text" => "Hello"},
...> cid: "bafyreib2rxk3rybk"
...> }
iex> Aether.ATProto.Record.to_map(record)
%{
"$type" => "app.bsky.feed.post",
"text" => "Hello",
"cid" => "bafyreib2rxk3rybk"
}
"""
@spec to_map(t()) :: map()
def to_map(%__MODULE__{type: type, data: data, cid: nil}) do
Map.put(data, "$type", type)
end
def to_map(%__MODULE__{type: type, data: data, cid: cid}) when is_binary(cid) do
data
|> Map.put("$type", type)
|> Map.put("cid", cid)
end
@doc """
Validate that a type string is a valid NSID.
## Examples
iex> Aether.ATProto.Record.valid_type?("app.bsky.feed.post")
true
iex> Aether.ATProto.Record.valid_type?("com.example.myapp.record")
true
iex> Aether.ATProto.Record.valid_type?("InvalidType")
false
iex> Aether.ATProto.Record.valid_type?("single")
false
"""
@spec valid_type?(String.t()) :: boolean()
def valid_type?(type) when is_binary(type) do
String.match?(type, @nsid_pattern)
end
def valid_type?(_), do: false
@doc """
Immutably update a field in the record's data.
Returns a new Record struct with the updated data.
## Examples
iex> record = %Aether.ATProto.Record{
...> type: "app.bsky.feed.post",
...> data: %{"text" => "Original"}
...> }
iex> updated = Aether.ATProto.Record.put_in_data(record, "text", "Updated")
iex> updated.data["text"]
"Updated"
iex> record = %Aether.ATProto.Record{
...> type: "app.bsky.feed.post",
...> data: %{"text" => "Hello"}
...> }
iex> updated = Aether.ATProto.Record.put_in_data(record, "likes", 42)
iex> updated.data
%{"text" => "Hello", "likes" => 42}
"""
@spec put_in_data(t(), String.t(), term()) :: t()
def put_in_data(%__MODULE__{data: data} = record, key, value) when is_binary(key) do
%{record | data: Map.put(data, key, value)}
end
@doc """
Get a field from the record's data with an optional default.
## Examples
iex> record = %Aether.ATProto.Record{
...> type: "app.bsky.feed.post",
...> data: %{"text" => "Hello", "likes" => 10}
...> }
iex> Aether.ATProto.Record.get_from_data(record, "text")
"Hello"
iex> record = %Aether.ATProto.Record{
...> type: "app.bsky.feed.post",
...> data: %{"text" => "Hello"}
...> }
iex> Aether.ATProto.Record.get_from_data(record, "likes", 0)
0
"""
@spec get_from_data(t(), String.t(), term()) :: term()
def get_from_data(%__MODULE__{data: data}, key, default \\ nil) when is_binary(key) do
Map.get(data, key, default)
end
end