Current section
Files
Jump to
Current section
Files
lib/aether/atproto/lexicon.ex
defmodule Aether.ATProto.Lexicon do
@moduledoc """
Lexicon schema loading and validation for ATProto.
Lexicons are ATProto's schema definition language, similar to JSON Schema
or OpenAPI. They define the structure and constraints for:
- Record types (e.g., `app.bsky.feed.post`)
- XRPC queries and procedures
- Event stream messages
## Usage
# Load a lexicon schema from JSON
{:ok, lexicon} = Aether.ATProto.Lexicon.load_schema(json_string)
# Validate data against a schema
data = %{
"text" => "Hello, ATProto!",
"createdAt" => "2024-01-15T12:00:00Z"
}
case Aether.ATProto.Lexicon.validate(lexicon, data) do
{:ok, validated_data} -> # Data is valid
{:error, errors} -> # Validation failed
end
## Type System
Lexicons support the following types:
**Primitives:**
- `null` - Null value
- `boolean` - True or false
- `integer` - Whole numbers
- `string` - Text strings
- `bytes` - Binary data (base64 encoded)
- `cid-link` - Content identifier link
- `blob` - Binary large object reference
**Containers:**
- `object` - Key-value map with defined properties
- `array` - Ordered list of items
**Meta:**
- `ref` - Reference to another schema
- `union` - One of multiple types
- `unknown` - Any value (no validation)
## Constraints
Types can have various constraints:
- `minLength`/`maxLength` - String/array length bounds
- `minimum`/`maximum` - Numeric value bounds
- `enum` - Allowed values list
- `required` - Required object properties
- `const` - Fixed value
- `default` - Default value if not provided
## Example
# Simple record schema
schema = %{
"type" => "object",
"properties" => %{
"text" => %{
"type" => "string",
"maxLength" => 300
},
"createdAt" => %{
"type" => "string",
"format" => "datetime"
}
},
"required" => ["text", "createdAt"]
}
lexicon = %Aether.ATProto.Lexicon{
nsid: "app.bsky.feed.post",
definition: schema
}
# Validate data
Aether.ATProto.Lexicon.validate(lexicon, %{
"text" => "Hello!",
"createdAt" => "2024-01-15T12:00:00Z"
})
#=> {:ok, %{"text" => "Hello!", "createdAt" => "2024-01-15T12:00:00Z"}}
"""
defstruct [:nsid, :version, :type, :definition]
@type t :: %__MODULE__{
nsid: String.t() | nil,
version: integer() | nil,
type: String.t() | nil,
definition: map()
}
@type validation_error :: %{
path: [String.t()],
message: String.t()
}
@doc """
Load a lexicon schema from a JSON string.
## Examples
iex> json = ~s({"lexicon": 1, "id": "app.bsky.feed.post", "defs": {"main": {"type": "record"}}})
iex> {:ok, lexicon} = Aether.ATProto.Lexicon.load_schema(json)
iex> lexicon.nsid
"app.bsky.feed.post"
"""
@spec load_schema(String.t()) :: {:ok, t()} | {:error, term()}
def load_schema(json_string) when is_binary(json_string) do
case Jason.decode(json_string) do
{:ok, schema_map} -> parse_schema(schema_map)
{:error, reason} -> {:error, {:json_parse_error, reason}}
end
end
@doc """
Load a lexicon schema from a map.
## Examples
iex> schema = %{"lexicon" => 1, "id" => "com.example.post", "defs" => %{"main" => %{"type" => "record"}}}
iex> {:ok, lexicon} = Aether.ATProto.Lexicon.load_schema_map(schema)
iex> lexicon.nsid
"com.example.post"
"""
@spec load_schema_map(map()) :: {:ok, t()} | {:error, term()}
def load_schema_map(schema_map) when is_map(schema_map) do
parse_schema(schema_map)
end
@doc """
Validate data against a lexicon schema.
Returns `{:ok, data}` if validation succeeds, or `{:error, errors}` if it fails.
Errors include the path to the invalid field and a description of the issue.
## Examples
iex> lexicon = %Aether.ATProto.Lexicon{
...> definition: %{
...> "type" => "object",
...> "properties" => %{
...> "text" => %{"type" => "string", "maxLength" => 10}
...> },
...> "required" => ["text"]
...> }
...> }
iex> Aether.ATProto.Lexicon.validate(lexicon, %{"text" => "Hello!"})
{:ok, %{"text" => "Hello!"}}
iex> lexicon = %Aether.ATProto.Lexicon{
...> definition: %{
...> "type" => "object",
...> "properties" => %{
...> "text" => %{"type" => "string", "maxLength" => 3}
...> },
...> "required" => ["text"]
...> }
...> }
iex> Aether.ATProto.Lexicon.validate(lexicon, %{"text" => "Hello!"})
{:error, [%{path: ["text"], message: "string length 6 exceeds maximum 3"}]}
"""
@spec validate(t(), term()) :: {:ok, term()} | {:error, [validation_error()]}
def validate(%__MODULE__{definition: definition}, data) do
case validate_value(data, definition, []) do
{:ok, _} -> {:ok, data}
{:error, errors} -> {:error, errors}
end
end
# Private functions
defp parse_schema(%{"lexicon" => 1, "id" => nsid, "defs" => defs}) do
# Get main definition or first definition
main_def = defs["main"] || Map.values(defs) |> List.first()
{:ok,
%__MODULE__{
nsid: nsid,
version: 1,
type: main_def["type"],
definition: main_def
}}
end
defp parse_schema(%{"type" => _type} = schema) do
# Simple schema without full lexicon wrapper
{:ok, %__MODULE__{definition: schema}}
end
defp parse_schema(_), do: {:error, :invalid_schema}
# Validation implementation
defp validate_value(value, %{"type" => type} = schema, path) do
validate_type(value, type, schema, path)
end
defp validate_value(_value, %{"const" => const_value}, _path) do
{:ok, const_value}
end
defp validate_value(_value, _schema, _path) do
# Unknown schema type, accept any value
{:ok, :valid}
end
# Type validation
defp validate_type(nil, "null", _schema, _path), do: {:ok, :valid}
defp validate_type(value, "boolean", _schema, _path) when is_boolean(value),
do: {:ok, :valid}
defp validate_type(value, "boolean", _schema, path) when not is_boolean(value) do
{:error, [error(path, "expected boolean, got #{inspect(value)}")]}
end
defp validate_type(value, "integer", schema, path) when is_integer(value) do
with :ok <- validate_minimum(value, schema["minimum"], path),
:ok <- validate_maximum(value, schema["maximum"], path),
:ok <- validate_enum(value, schema["enum"], path) do
{:ok, :valid}
end
end
defp validate_type(value, "integer", _schema, path) when not is_integer(value) do
{:error, [error(path, "expected integer, got #{inspect(value)}")]}
end
defp validate_type(value, "string", schema, path) when is_binary(value) do
with :ok <- validate_min_length(value, schema["minLength"], path),
:ok <- validate_max_length(value, schema["maxLength"], path),
:ok <- validate_max_graphemes(value, schema["maxGraphemes"], path),
:ok <- validate_enum(value, schema["enum"], path) do
{:ok, :valid}
end
end
defp validate_type(value, "string", _schema, path) when not is_binary(value) do
{:error, [error(path, "expected string, got #{inspect(value)}")]}
end
defp validate_type(value, "object", schema, path) when is_map(value) do
with :ok <- validate_required_properties(value, schema["required"], path),
{:ok, _} <- validate_properties(value, schema["properties"], path) do
{:ok, :valid}
end
end
defp validate_type(value, "object", _schema, path) when not is_map(value) do
{:error, [error(path, "expected object, got #{inspect(value)}")]}
end
defp validate_type(value, "array", schema, path) when is_list(value) do
with :ok <- validate_array_length(value, schema, path),
{:ok, _} <- validate_array_items(value, schema["items"], path) do
{:ok, :valid}
end
end
defp validate_type(value, "array", _schema, path) when not is_list(value) do
{:error, [error(path, "expected array, got #{inspect(value)}")]}
end
defp validate_type(_value, "unknown", _schema, _path) do
# Unknown type accepts anything
{:ok, :valid}
end
defp validate_type(_value, "bytes", _schema, _path) do
# TODO: Validate base64 encoded bytes
{:ok, :valid}
end
defp validate_type(_value, "cid-link", _schema, _path) do
# TODO: Validate CID format
{:ok, :valid}
end
defp validate_type(_value, "blob", _schema, _path) do
# TODO: Validate blob reference format
{:ok, :valid}
end
defp validate_type(value, type, _schema, path) do
{:error, [error(path, "unknown type: #{type} for value #{inspect(value)}")]}
end
# Constraint validation
defp validate_minimum(_value, nil, _path), do: :ok
defp validate_minimum(value, minimum, _path) when value >= minimum, do: :ok
defp validate_minimum(value, minimum, path) do
{:error, [error(path, "value #{value} is less than minimum #{minimum}")]}
end
defp validate_maximum(_value, nil, _path), do: :ok
defp validate_maximum(value, maximum, _path) when value <= maximum, do: :ok
defp validate_maximum(value, maximum, path) do
{:error, [error(path, "value #{value} exceeds maximum #{maximum}")]}
end
defp validate_min_length(_value, nil, _path), do: :ok
defp validate_min_length(value, min_length, path) do
actual_length = String.length(value)
if actual_length >= min_length do
:ok
else
{:error, [error(path, "string length #{actual_length} is less than minimum #{min_length}")]}
end
end
defp validate_max_length(_value, nil, _path), do: :ok
defp validate_max_length(value, max_length, path) do
actual_length = String.length(value)
if actual_length <= max_length do
:ok
else
{:error, [error(path, "string length #{actual_length} exceeds maximum #{max_length}")]}
end
end
defp validate_max_graphemes(_value, nil, _path), do: :ok
defp validate_max_graphemes(value, max_graphemes, path) do
grapheme_count = String.length(value)
if grapheme_count <= max_graphemes do
:ok
else
{:error,
[error(path, "string graphemes #{grapheme_count} exceeds maximum #{max_graphemes}")]}
end
end
defp validate_enum(_value, nil, _path), do: :ok
defp validate_enum(value, enum, path) when is_list(enum) do
if value in enum do
:ok
else
{:error, [error(path, "value #{inspect(value)} not in enum #{inspect(enum)}")]}
end
end
# Object validation
defp validate_required_properties(_object, nil, _path), do: :ok
defp validate_required_properties(object, required, path) when is_list(required) do
missing =
Enum.filter(required, fn prop ->
not Map.has_key?(object, prop)
end)
if Enum.empty?(missing) do
:ok
else
errors =
Enum.map(missing, fn prop ->
error(path ++ [prop], "required property missing")
end)
{:error, errors}
end
end
defp validate_properties(_object, nil, _path), do: {:ok, :valid}
defp validate_properties(object, properties, path) when is_map(properties) do
errors =
Enum.flat_map(properties, fn {prop_name, prop_schema} ->
case Map.fetch(object, prop_name) do
{:ok, prop_value} ->
case validate_value(prop_value, prop_schema, path ++ [prop_name]) do
{:ok, _} -> []
{:error, errs} -> errs
end
:error ->
[]
end
end)
if Enum.empty?(errors) do
{:ok, :valid}
else
{:error, errors}
end
end
# Array validation
defp validate_array_length(array, schema, path) do
length = length(array)
with :ok <- validate_array_min_length(length, schema["minLength"], path),
:ok <- validate_array_max_length(length, schema["maxLength"], path) do
:ok
end
end
defp validate_array_min_length(_length, nil, _path), do: :ok
defp validate_array_min_length(length, min_length, _path) when length >= min_length, do: :ok
defp validate_array_min_length(length, min_length, path) do
{:error, [error(path, "array length #{length} is less than minimum #{min_length}")]}
end
defp validate_array_max_length(_length, nil, _path), do: :ok
defp validate_array_max_length(length, max_length, _path) when length <= max_length, do: :ok
defp validate_array_max_length(length, max_length, path) do
{:error, [error(path, "array length #{length} exceeds maximum #{max_length}")]}
end
defp validate_array_items(_array, nil, _path), do: {:ok, :valid}
defp validate_array_items(array, items_schema, path) do
errors =
array
|> Enum.with_index()
|> Enum.flat_map(fn {item, index} ->
case validate_value(item, items_schema, path ++ ["[#{index}]"]) do
{:ok, _} -> []
{:error, errs} -> errs
end
end)
if Enum.empty?(errors) do
{:ok, :valid}
else
{:error, errors}
end
end
# Error helpers
defp error(path, message) do
%{path: path, message: message}
end
end