A set of utilities for working with the AT Protocol in Elixir.

feat: `deflexicon` macro for coverting Lexicons into runtime validation schemas

ovyerus.com 28303d87 5f5c37c0

verified
+4 -1
.formatter.exs
···
# Used by "mix format"
[
inputs: ["{mix,.formatter,.credo}.exs", "{config,lib,test}/**/*.{ex,exs}"],
-
import_deps: [:typedstruct, :peri]
]
···
# Used by "mix format"
[
inputs: ["{mix,.formatter,.credo}.exs", "{config,lib,test}/**/*.{ex,exs}"],
+
import_deps: [:typedstruct, :peri],
+
export: [
+
locals_without_parens: [deflexicon: 1]
+
]
]
+7 -1
CHANGELOG.md
···
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-
<!-- ## [Unreleased] -->
## [0.3.0] - 2025-06-29
···
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
## [Unreleased]
+
+
### Added
+
+
- `Atex.Lexicon` module that provides the `deflexicon` macro, taking in a JSON
+
Lexicon definition and converts it into a series of schemas for each
+
definition within it.
## [0.3.0] - 2025-06-29
+309
lib/atex/lexicon.ex
···
···
+
defmodule Atex.Lexicon do
+
@moduledoc """
+
Provide `deflexicon` macro for defining a module with types and schemas from an entire lexicon definition.
+
+
Should it also define structs, with functions to convert from input case to snake case?
+
"""
+
+
alias Atex.Lexicon.Validators
+
+
defmacro __using__(_opts) do
+
quote do
+
import Atex.Lexicon
+
import Atex.Lexicon.Validators
+
import Peri
+
end
+
end
+
+
defmacro deflexicon(lexicon) do
+
# Better way to get the real map, without having to eval? (custom function to compose one from quoted?)
+
lexicon =
+
lexicon
+
|> Code.eval_quoted()
+
|> elem(0)
+
|> then(&Recase.Enumerable.atomize_keys/1)
+
|> then(&Atex.Lexicon.Schema.lexicon!/1)
+
+
# TODO: support returning typedefs
+
defs =
+
lexicon.defs
+
|> Enum.flat_map(fn {def_name, def} -> def_to_schema(lexicon.id, def_name, def) end)
+
|> Enum.map(fn {schema_key, quoted_schema} ->
+
quote do
+
defschema unquote(schema_key), unquote(quoted_schema)
+
end
+
end)
+
+
quote do
+
def id, do: unquote(Atex.NSID.to_atom(lexicon.id))
+
+
unquote_splicing(defs)
+
end
+
end
+
+
# TODO: generate typedefs
+
@spec def_to_schema(nsid :: String.t(), def_name :: String.t(), lexicon_def :: map()) ::
+
list({key :: atom(), quoted :: term()})
+
+
defp def_to_schema(nsid, def_name, %{type: "record", record: record}) do
+
# TODO: record rkey format validator
+
def_to_schema(nsid, def_name, record)
+
end
+
+
defp def_to_schema(
+
nsid,
+
def_name,
+
%{
+
type: "object",
+
properties: properties,
+
required: required
+
} = def
+
) do
+
nullable = Map.get(def, :nullable, [])
+
+
properties
+
|> Enum.map(fn {key, field} ->
+
field_to_schema(field, nsid)
+
|> then(
+
&if key in nullable, do: quote(do: {:either, {{:literal, nil}, unquote(&1)}}), else: &1
+
)
+
|> then(&if key in required, do: quote(do: {:required, unquote(&1)}), else: &1)
+
|> then(&{key, &1})
+
end)
+
|> then(&{:%{}, [], &1})
+
|> then(&[{atomise(def_name), &1}])
+
end
+
+
# TODO: validating errors?
+
defp def_to_schema(nsid, _def_name, %{type: "query"} = def) do
+
params =
+
if def[:parameters] do
+
[schema] =
+
def_to_schema(nsid, "params", %{
+
type: "object",
+
required: def.parameters.required,
+
nullable: [],
+
properties: def.parameters.properties
+
})
+
+
schema
+
end
+
+
output =
+
if def.output && def.output.schema do
+
[schema] = def_to_schema(nsid, "output", def.output.schema)
+
schema
+
end
+
+
[params, output]
+
|> Enum.reject(&is_nil/1)
+
end
+
+
defp def_to_schema(nsid, _def_name, %{type: "procedure"} = def) do
+
# TODO: better keys for these
+
params =
+
if def[:parameters] do
+
[schema] =
+
def_to_schema(nsid, "params", %{
+
type: "object",
+
required: def.parameters.required,
+
properties: def.parameters.properties
+
})
+
+
schema
+
end
+
+
output =
+
if def[:output] && def.output.schema do
+
[schema] = def_to_schema(nsid, "output", def.output.schema)
+
schema
+
end
+
+
input =
+
if def[:input] && def.input.schema do
+
[schema] = def_to_schema(nsid, "output", def.input.schema)
+
schema
+
end
+
+
[params, output, input]
+
|> Enum.reject(&is_nil/1)
+
end
+
+
defp def_to_schema(nsid, _def_name, %{type: "subscription"} = def) do
+
params =
+
if def[:parameters] do
+
[schema] =
+
def_to_schema(nsid, "params", %{
+
type: "object",
+
required: def.parameters.required,
+
properties: def.parameters.properties
+
})
+
+
schema
+
end
+
+
message =
+
if def[:message] do
+
[schema] = def_to_schema(nsid, "message", def.message.schema)
+
schema
+
end
+
+
[params, message]
+
|> Enum.reject(&is_nil/1)
+
end
+
+
defp def_to_schema(_nsid, def_name, %{type: "token"}) do
+
# TODO: make it a validator that expects the nsid + key.
+
[{atomise(def_name), :string}]
+
end
+
+
defp def_to_schema(nsid, def_name, %{type: type} = def)
+
when type in [
+
"blob",
+
"array",
+
"boolean",
+
"integer",
+
"string",
+
"bytes",
+
"cid-link",
+
"unknown"
+
] do
+
[{atomise(def_name), field_to_schema(def, nsid)}]
+
end
+
+
@spec field_to_schema(field_def :: %{type: String.t()}, nsid :: String.t()) :: Peri.schema_def()
+
defp field_to_schema(%{type: "string"} = field, _nsid) do
+
fixed_schema = const_or_enum(field)
+
+
if fixed_schema do
+
maybe_default(fixed_schema, field)
+
else
+
field
+
|> Map.take([
+
:format,
+
:maxLength,
+
:minLength,
+
:maxGraphemes,
+
:minGraphemes
+
])
+
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
+
|> then(&{:custom, {Validators.String, :validate, [&1]}})
+
|> maybe_default(field)
+
|> then(&Macro.escape/1)
+
end
+
end
+
+
defp field_to_schema(%{type: "boolean"} = field, _nsid) do
+
(const(field) || :boolean)
+
|> maybe_default(field)
+
|> then(&Macro.escape/1)
+
end
+
+
defp field_to_schema(%{type: "integer"} = field, _nsid) do
+
fixed_schema = const_or_enum(field)
+
+
if fixed_schema do
+
maybe_default(fixed_schema, field)
+
else
+
field
+
|> Map.take([:maximum, :minimum])
+
|> Keyword.new()
+
|> then(&{:custom, {Validators.Integer, [&1]}})
+
|> maybe_default(field)
+
end
+
|> then(&Macro.escape/1)
+
end
+
+
defp field_to_schema(%{type: "array", items: items} = field, nsid) do
+
inner_schema = field_to_schema(items, nsid)
+
+
field
+
|> Map.take([:maxLength, :minLength])
+
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
+
|> then(&Validators.array(inner_schema, &1))
+
|> then(&Macro.escape/1)
+
# Can't unquote the inner_schema beforehand as that would risk evaluating `get_schema`s which don't exist yet.
+
# There's probably a better way to do this lol.
+
|> then(fn {:custom, {:{}, c, [Validators.Array, :validate, [quoted_inner_schema | args]]}} ->
+
{inner_schema, _} = Code.eval_quoted(quoted_inner_schema)
+
{:custom, {:{}, c, [Validators.Array, :validate, [inner_schema | args]]}}
+
end)
+
end
+
+
defp field_to_schema(%{type: "blob"} = field, _nsid) do
+
field
+
|> Map.take([:accept, :maxSize])
+
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
+
|> Validators.blob()
+
|> then(&Macro.escape/1)
+
end
+
+
defp field_to_schema(%{type: "bytes"} = field, _nsid) do
+
field
+
|> Map.take([:maxLength, :minLength])
+
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
+
|> Validators.bytes()
+
|> then(&Macro.escape/1)
+
end
+
+
defp field_to_schema(%{type: "cid-link"}, _nsid) do
+
Validators.cid_link()
+
|> then(&Macro.escape/1)
+
end
+
+
# TODO: do i need to make sure these two deal with brands? Check objects in atp.tools
+
defp field_to_schema(%{type: "ref", ref: ref}, nsid) do
+
{nsid, fragment} =
+
nsid
+
|> Atex.NSID.expand_possible_fragment_shorthand(ref)
+
|> Atex.NSID.to_atom_with_fragment()
+
+
quote do
+
unquote(nsid).get_schema(unquote(fragment))
+
end
+
end
+
+
defp field_to_schema(%{type: "union", refs: refs}, nsid) do
+
# refs =
+
refs
+
|> Enum.map(fn ref ->
+
{nsid, fragment} =
+
nsid
+
|> Atex.NSID.expand_possible_fragment_shorthand(ref)
+
|> Atex.NSID.to_atom_with_fragment()
+
+
quote do
+
unquote(nsid).get_schema(unquote(fragment))
+
end
+
end)
+
|> then(
+
&quote do
+
{:oneof, unquote(&1)}
+
end
+
)
+
end
+
+
# TODO: apparently should be a data object, not a primitive?
+
defp field_to_schema(%{type: "unknown"}, _nsid) do
+
:any
+
end
+
+
defp field_to_schema(_field_def, _nsid), do: nil
+
+
defp maybe_default(schema, field) do
+
if field[:default] != nil,
+
do: {schema, {:default, field.default}},
+
else: schema
+
end
+
+
defp const_or_enum(field), do: const(field) || enum(field)
+
+
defp const(%{const: value}), do: {:literal, value}
+
defp const(_), do: nil
+
+
defp enum(%{enum: values}), do: {:enum, values}
+
defp enum(_), do: nil
+
+
defp atomise(x) when is_atom(x), do: x
+
defp atomise(x) when is_binary(x), do: String.to_atom(x)
+
end
+265
lib/atex/lexicon/schema.ex
···
···
+
defmodule Atex.Lexicon.Schema do
+
import Peri
+
+
defschema :lexicon, %{
+
lexicon: {:required, {:literal, 1}},
+
id:
+
{:required,
+
{:string,
+
{:regex,
+
~r/^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z]([a-zA-Z]{0,61}[a-zA-Z])?)$/}}},
+
revision: {:integer, {:gte, 0}},
+
description: :string,
+
defs: {
+
:required,
+
{:schema,
+
%{
+
main:
+
{:oneof,
+
[
+
get_schema(:record),
+
get_schema(:query),
+
get_schema(:procedure),
+
get_schema(:subscription),
+
get_schema(:user_types)
+
]}
+
}, {:additional_keys, get_schema(:user_types)}}
+
}
+
}
+
+
defschema :record, %{
+
type: {:required, {:literal, "record"}},
+
description: :string,
+
# TODO: constraint
+
key: {:required, :string},
+
record: {:required, get_schema(:object)}
+
}
+
+
defschema :query, %{
+
type: {:required, {:literal, "query"}},
+
description: :string,
+
parameters: get_schema(:parameters),
+
output: get_schema(:body),
+
errors: {:list, get_schema(:error)}
+
}
+
+
defschema :procedure, %{
+
type: {:required, {:literal, "procedure"}},
+
description: :string,
+
parameters: get_schema(:parameters),
+
input: get_schema(:body),
+
output: get_schema(:body),
+
errors: {:list, get_schema(:error)}
+
}
+
+
defschema :subscription, %{
+
type: {:required, {:literal, "subscription"}},
+
description: :string,
+
parameters: get_schema(:parameters),
+
message: %{
+
description: :string,
+
schema: {:oneof, [get_schema(:object), get_schema(:ref_variant)]}
+
},
+
errors: {:list, get_schema(:error)}
+
}
+
+
defschema :parameters, %{
+
type: {:required, {:literal, "params"}},
+
description: :string,
+
required: {{:list, :string}, {:default, []}},
+
properties:
+
{:required, {:map, {:either, {get_schema(:primitive), get_schema(:primitive_array)}}}}
+
}
+
+
defschema :body, %{
+
description: :string,
+
encoding: {:required, :string},
+
schema: {:oneof, [get_schema(:object), get_schema(:ref_variant)]}
+
}
+
+
defschema :error, %{
+
name: {:required, :string},
+
description: :string
+
}
+
+
defschema :user_types,
+
{:oneof,
+
[
+
get_schema(:blob),
+
get_schema(:array),
+
get_schema(:token),
+
get_schema(:object),
+
get_schema(:boolean),
+
get_schema(:integer),
+
get_schema(:string),
+
get_schema(:bytes),
+
get_schema(:cid_link),
+
get_schema(:unknown)
+
]}
+
+
# General types
+
+
@ref_value {:string,
+
{
+
:regex,
+
# TODO: minlength 1
+
~r/^(?=.)(?:[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?:\.[a-zA-Z](?:[a-zA-Z]{0,61}[a-zA-Z])?))?(?:#[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)?$/
+
}}
+
+
@positive_int {:integer, {:gte, 0}}
+
@nonzero_positive_int {:integer, {:gt, 0}}
+
+
defschema :ref_variant, {:oneof, [get_schema(:ref), get_schema(:ref_union)]}
+
+
defschema :ref, %{
+
type: {:required, {:literal, "ref"}},
+
description: :string,
+
ref: {:required, @ref_value}
+
}
+
+
defschema :ref_union, %{
+
type: {:required, {:literal, "union"}},
+
description: :string,
+
refs: {:required, {:list, @ref_value}}
+
}
+
+
defschema :array, %{
+
type: {:required, {:literal, "array"}},
+
description: :string,
+
items:
+
{:required,
+
{:oneof,
+
[get_schema(:primitive), get_schema(:ipld), get_schema(:blob), get_schema(:ref_variant)]}},
+
maxLength: @positive_int,
+
minLength: @positive_int
+
}
+
+
defschema :primitive_array, %{
+
type: {:required, {:literal, "array"}},
+
description: :string,
+
items: {:required, get_schema(:primitive)},
+
maxLength: @positive_int,
+
minLength: @positive_int
+
}
+
+
defschema :object, %{
+
type: {:required, {:literal, "object"}},
+
description: :string,
+
required: {{:list, :string}, {:default, []}},
+
nullable: {{:list, :string}, {:default, []}},
+
properties:
+
{:required,
+
{:map,
+
{:oneof,
+
[
+
get_schema(:ref_variant),
+
get_schema(:ipld),
+
get_schema(:array),
+
get_schema(:blob),
+
get_schema(:primitive)
+
]}}}
+
}
+
+
defschema :primitive,
+
{:oneof,
+
[
+
get_schema(:boolean),
+
get_schema(:integer),
+
get_schema(:string),
+
get_schema(:unknown)
+
]}
+
+
defschema :ipld, {:oneof, [get_schema(:bytes), get_schema(:cid_link)]}
+
+
defschema :blob, %{
+
type: {:required, {:literal, "blob"}},
+
description: :string,
+
accept: {:list, :string},
+
maxSize: @positive_int
+
}
+
+
defschema :boolean, %{
+
type: {:required, {:literal, "boolean"}},
+
description: :string,
+
default: :boolean,
+
const: :boolean
+
}
+
+
defschema :bytes, %{
+
type: {:required, {:literal, "bytes"}},
+
description: :string,
+
maxLength: @positive_int,
+
minLength: @positive_int
+
}
+
+
defschema :cid_link, %{
+
type: {:required, {:literal, "cid-link"}},
+
description: :string
+
}
+
+
@string_type {:required, {:literal, "string"}}
+
+
defschema :string,
+
{:either,
+
{
+
# Formatted
+
%{
+
type: @string_type,
+
format:
+
{:required,
+
{:enum,
+
[
+
"at-identifier",
+
"at-uri",
+
"cid",
+
"datetime",
+
"did",
+
"handle",
+
"language",
+
"nsid",
+
"record-key",
+
"tid",
+
"uri"
+
]}},
+
description: :string,
+
default: :string,
+
const: :string,
+
enum: {:list, :string},
+
knownValues: {:list, :string}
+
},
+
# Unformatted
+
%{
+
type: @string_type,
+
description: :string,
+
default: :string,
+
const: :string,
+
enum: {:list, :string},
+
knownValues: {:list, :string},
+
format: {:literal, nil},
+
maxLength: @nonzero_positive_int,
+
minLength: @nonzero_positive_int,
+
maxGraphemes: @nonzero_positive_int,
+
minGraphemes: @nonzero_positive_int
+
}
+
}}
+
+
defschema :integer, %{
+
type: {:required, {:literal, "integer"}},
+
description: :string,
+
default: @positive_int,
+
const: @positive_int,
+
enum: {:list, @positive_int},
+
maximum: @positive_int,
+
minimum: @positive_int
+
}
+
+
defschema :token, %{
+
type: {:required, {:literal, "token"}},
+
description: :string
+
}
+
+
defschema :unknown, %{
+
type: {:required, {:literal, "unknown"}},
+
description: :string
+
}
+
end
+26 -5
lib/atex/lexicon/validators.ex
···
defmodule Atex.Lexicon.Validators do
alias Atex.Lexicon.Validators
-
@type blob_option() :: {:accept, list(String.t())} | {:max_size, integer()}
@type blob_t() ::
%{
"$type": String.t(),
-
req: %{"$link": String.t()},
mimeType: String.t(),
size: integer()
}
-
| %{}
@spec string(list(Validators.String.option())) :: Peri.custom_def()
def string(options \\ []), do: {:custom, {Validators.String, :validate, [options]}}
···
@spec array(Peri.schema_def(), list(Validators.Array.option())) :: Peri.custom_def()
def array(inner_type, options \\ []) do
-
{:ok, ^inner_type} = Peri.validate_schema(inner_type)
{:custom, {Validators.Array, :validate, [inner_type, options]}}
end
···
},
# Old deprecated blobs
%{
-
cid: {:reqiured, :string},
mimeType: mime_type
}
}
}
end
···
defmodule Atex.Lexicon.Validators do
alias Atex.Lexicon.Validators
+
@type blob_option() :: {:accept, list(String.t())} | {:max_size, pos_integer()}
@type blob_t() ::
%{
"$type": String.t(),
+
ref: %{"$link": String.t()},
mimeType: String.t(),
size: integer()
}
+
| %{
+
cid: String.t(),
+
mimeType: String.t()
+
}
@spec string(list(Validators.String.option())) :: Peri.custom_def()
def string(options \\ []), do: {:custom, {Validators.String, :validate, [options]}}
···
@spec array(Peri.schema_def(), list(Validators.Array.option())) :: Peri.custom_def()
def array(inner_type, options \\ []) do
{:custom, {Validators.Array, :validate, [inner_type, options]}}
end
···
},
# Old deprecated blobs
%{
+
cid: {:required, :string},
mimeType: mime_type
}
}
+
}
+
end
+
+
@spec bytes(list(Validators.Bytes.option())) :: Peri.schema()
+
def bytes(options \\ []) do
+
options = Keyword.validate!(options, min_length: nil, max_length: nil)
+
+
%{
+
"$bytes":
+
{:required,
+
{{:custom, {Validators.Bytes, :validate, [options]}}, {:transform, &Base.decode64!/1}}}
+
}
+
end
+
+
# TODO: see what atcute validators expect
+
# TODO: cid validation?
+
def cid_link() do
+
%{
+
"$link": {:required, :string}
}
end
+32
lib/atex/lexicon/validators/bytes.ex
···
···
+
defmodule Atex.Lexicon.Validators.Bytes do
+
@type option() :: {:min_length, pos_integer()} | {:max_length, pos_integer()}
+
+
@option_keys [:min_length, :max_length]
+
+
@spec validate(term(), list(option())) :: Peri.validation_result()
+
def validate(value, options) when is_binary(value) do
+
case Base.decode64(value, padding: false) do
+
{:ok, bytes} ->
+
options
+
|> Keyword.validate!(min_length: nil, max_length: nil)
+
|> Stream.map(&validate_option(bytes, &1))
+
|> Enum.find(:ok, fn x -> x !== :ok end)
+
+
:error ->
+
{:error, "expected valid base64 encoded bytes", []}
+
end
+
end
+
+
def validate(value, _options),
+
do:
+
{:error, "expected valid base64 encoded bytes, received #{value}",
+
[expected: :bytes, actual: value]}
+
+
defp validate_option(_value, {option, nil}) when option in @option_keys, do: :ok
+
+
defp validate_option(value, {:min_length, expected}) when byte_size(value) < expected,
+
do: {:error, "should have a minimum byte length of #{expected}", [length: expected]}
+
+
defp validate_option(value, {:max_length, expected}) when byte_size(value) <= expected,
+
do: :ok
+
end
+2 -17
lib/atex/lexicon/validators/integer.ex
···
@type option() ::
{:minimum, integer()}
| {:maximum, integer()}
-
| {:enum, list(integer())}
-
| {:const, integer()}
-
@option_keys [:minimum, :maximum, :enum, :const]
@spec validate(term(), list(option())) :: Peri.validation_result()
def validate(value, options) when is_integer(value) do
options
|> Keyword.validate!(
minimum: nil,
-
maximum: nil,
-
enum: nil,
-
const: nil
)
|> Stream.map(&validate_option(value, &1))
|> Enum.find(:ok, fn x -> x != :ok end)
···
defp validate_option(value, {:maximum, expected}) when value > expected,
do: {:error, "", [value: expected]}
-
-
defp validate_option(value, {:enum, values}),
-
do:
-
Validators.boolean_validate(value in values, "should be one of the expected values",
-
enum: values
-
)
-
-
defp validate_option(value, {:const, expected}) when value == expected, do: :ok
-
-
defp validate_option(value, {:const, expected}),
-
do: {:error, "should match constant value", [actual: value, expected: expected]}
end
···
@type option() ::
{:minimum, integer()}
| {:maximum, integer()}
+
@option_keys [:minimum, :maximum]
@spec validate(term(), list(option())) :: Peri.validation_result()
def validate(value, options) when is_integer(value) do
options
|> Keyword.validate!(
minimum: nil,
+
maximum: nil
)
|> Stream.map(&validate_option(value, &1))
|> Enum.find(:ok, fn x -> x != :ok end)
···
defp validate_option(value, {:maximum, expected}) when value > expected,
do: {:error, "", [value: expected]}
end
+2 -19
lib/atex/lexicon/validators/string.ex
···
| {:max_length, non_neg_integer()}
| {:min_graphemes, non_neg_integer()}
| {:max_graphemes, non_neg_integer()}
-
| {:enum, list(String.t())}
-
| {:const, String.t()}
@option_keys [
:format,
:min_length,
:max_length,
:min_graphemes,
-
:max_graphemes,
-
:enum,
-
:const
]
@record_key_re ~r"^[a-zA-Z0-9.-_:~]$"
···
min_length: nil,
max_length: nil,
min_graphemes: nil,
-
max_graphemes: nil,
-
enum: nil,
-
const: nil
)
# Stream so we early exit at the first error.
|> Stream.map(&validate_option(value, &1))
···
"should have a maximum length of #{expected}",
length: expected
)
-
-
defp validate_option(value, {:enum, values}),
-
do:
-
Validators.boolean_validate(value in values, "should be one of the expected values",
-
enum: values
-
)
-
-
defp validate_option(value, {:const, expected}) when value == expected, do: :ok
-
-
defp validate_option(value, {:const, expected}),
-
do: {:error, "should match constant value", [actual: value, expected: expected]}
end
···
| {:max_length, non_neg_integer()}
| {:min_graphemes, non_neg_integer()}
| {:max_graphemes, non_neg_integer()}
@option_keys [
:format,
:min_length,
:max_length,
:min_graphemes,
+
:max_graphemes
]
@record_key_re ~r"^[a-zA-Z0-9.-_:~]$"
···
min_length: nil,
max_length: nil,
min_graphemes: nil,
+
max_graphemes: nil
)
# Stream so we early exit at the first error.
|> Stream.map(&validate_option(value, &1))
···
"should have a maximum length of #{expected}",
length: expected
)
end
+29
lib/atex/nsid.ex
···
# TODO: methods for fetching the authority and name from a nsid.
# maybe stuff for fetching the repo that belongs to an authority
end
···
# TODO: methods for fetching the authority and name from a nsid.
# maybe stuff for fetching the repo that belongs to an authority
+
+
@spec to_atom(String.t()) :: atom()
+
def to_atom(nsid) do
+
nsid
+
|> String.split(".")
+
|> Enum.map(&String.capitalize/1)
+
|> then(&["Elixir" | &1])
+
|> Enum.join(".")
+
|> String.to_atom()
+
end
+
+
@spec to_atom_with_fragment(String.t()) :: {atom(), atom()}
+
def to_atom_with_fragment(nsid) do
+
if !String.contains?(nsid, "#") do
+
{to_atom(nsid), :main}
+
else
+
[nsid, fragment] = String.split(nsid, "#")
+
{to_atom(nsid), String.to_atom(fragment)}
+
end
+
end
+
+
@spec expand_possible_fragment_shorthand(String.t(), String.t()) :: String.t()
+
def expand_possible_fragment_shorthand(main_nsid, possible_fragment) do
+
if String.starts_with?(possible_fragment, "#") do
+
main_nsid <> possible_fragment
+
else
+
possible_fragment
+
end
+
end
end
+23
lib/atex/peri.ex
···
end
defp validate_uri(uri), do: {:error, "must be a valid URI", [uri: uri]}
end
···
end
defp validate_uri(uri), do: {:error, "must be a valid URI", [uri: uri]}
+
+
def validate_map(value, schema, extra_keys_schema) when is_map(value) and is_map(schema) do
+
extra_keys =
+
Enum.reduce(Map.keys(schema), MapSet.new(Map.keys(value)), fn key, acc ->
+
acc |> MapSet.delete(key) |> MapSet.delete(to_string(key))
+
end)
+
+
extra_data =
+
value
+
|> Enum.filter(fn {key, _} -> MapSet.member?(extra_keys, key) end)
+
|> Map.new()
+
+
with {:ok, schema_data} <- Peri.validate(schema, value),
+
{:ok, extra_data} <- Peri.validate(extra_keys_schema, extra_data) do
+
{:ok, Map.merge(schema_data, extra_data)}
+
else
+
{:error, %Peri.Error{} = err} -> {:error, [err]}
+
e -> e
+
end
+
end
+
+
def validate_map(value, _schema, _extra_keys_schema),
+
do: {:error, "must be a map", [value: value]}
end
+115
lib/atproto/sh/comet/v0/actor/profile.ex
···
···
+
defmodule Sh.Comet.V0.Actor.Profile do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "A user's Comet profile.",
+
"key" => "literal:self",
+
"record" => %{
+
"properties" => %{
+
"avatar" => %{
+
"accept" => ["image/png", "image/jpeg"],
+
"description" =>
+
"Small image to be displayed next to posts from account. AKA, 'profile picture'",
+
"maxSize" => 1_000_000,
+
"type" => "blob"
+
},
+
"banner" => %{
+
"accept" => ["image/png", "image/jpeg"],
+
"description" => "Larger horizontal image to display behind profile view.",
+
"maxSize" => 1_000_000,
+
"type" => "blob"
+
},
+
"createdAt" => %{"format" => "datetime", "type" => "string"},
+
"description" => %{
+
"description" => "Free-form profile description text.",
+
"maxGraphemes" => 256,
+
"maxLength" => 2560,
+
"type" => "string"
+
},
+
"descriptionFacets" => %{
+
"description" => "Annotations of the user's description.",
+
"ref" => "sh.comet.v0.richtext.facet",
+
"type" => "ref"
+
},
+
"displayName" => %{
+
"maxGraphemes" => 64,
+
"maxLength" => 640,
+
"type" => "string"
+
},
+
"featuredItems" => %{
+
"description" => "Pinned items to be shown first on the user's profile.",
+
"items" => %{"format" => "at-uri", "type" => "string"},
+
"maxLength" => 5,
+
"type" => "array"
+
}
+
},
+
"type" => "object"
+
},
+
"type" => "record"
+
},
+
"view" => %{
+
"properties" => %{
+
"avatar" => %{"format" => "uri", "type" => "string"},
+
"createdAt" => %{"format" => "datetime", "type" => "string"},
+
"did" => %{"format" => "did", "type" => "string"},
+
"displayName" => %{
+
"maxGraphemes" => 64,
+
"maxLength" => 640,
+
"type" => "string"
+
},
+
"handle" => %{"format" => "handle", "type" => "string"},
+
"indexedAt" => %{"format" => "datetime", "type" => "string"},
+
"viewer" => %{"ref" => "#viewerState", "type" => "ref"}
+
},
+
"required" => ["did", "handle"],
+
"type" => "object"
+
},
+
"viewFull" => %{
+
"properties" => %{
+
"avatar" => %{"format" => "uri", "type" => "string"},
+
"banner" => %{"format" => "uri", "type" => "string"},
+
"createdAt" => %{"format" => "datetime", "type" => "string"},
+
"description" => %{
+
"maxGraphemes" => 256,
+
"maxLength" => 2560,
+
"type" => "string"
+
},
+
"descriptionFacets" => %{
+
"ref" => "sh.comet.v0.richtext.facet",
+
"type" => "ref"
+
},
+
"did" => %{"format" => "did", "type" => "string"},
+
"displayName" => %{
+
"maxGraphemes" => 64,
+
"maxLength" => 640,
+
"type" => "string"
+
},
+
"featuredItems" => %{
+
"items" => %{"format" => "at-uri", "type" => "string"},
+
"maxLength" => 5,
+
"type" => "array"
+
},
+
"followersCount" => %{"type" => "integer"},
+
"followsCount" => %{"type" => "integer"},
+
"handle" => %{"format" => "handle", "type" => "string"},
+
"indexedAt" => %{"format" => "datetime", "type" => "string"},
+
"playlistsCount" => %{"type" => "integer"},
+
"tracksCount" => %{"type" => "integer"},
+
"viewer" => %{"ref" => "#viewerState", "type" => "ref"}
+
},
+
"required" => ["did", "handle"],
+
"type" => "object"
+
},
+
"viewerState" => %{
+
"description" =>
+
"Metadata about the requesting account's relationship with the user. TODO: determine if we create our own graph or inherit bsky's.",
+
"properties" => %{},
+
"type" => "object"
+
}
+
},
+
"id" => "sh.comet.v0.actor.profile",
+
"lexicon" => 1
+
})
+
end
+44
lib/atproto/sh/comet/v0/feed/defs.ex
···
···
+
defmodule Sh.Comet.V0.Feed.Defs do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"buyLink" => %{
+
"description" => "Indicate the link leads to a purchase page for the track.",
+
"type" => "token"
+
},
+
"downloadLink" => %{
+
"description" => "Indicate the link leads to a free download for the track.",
+
"type" => "token"
+
},
+
"link" => %{
+
"description" =>
+
"Link for the track. Usually to acquire it in some way, e.g. via free download or purchase. | TODO: multiple links?",
+
"properties" => %{
+
"type" => %{
+
"knownValues" => [
+
"sh.comet.v0.feed.defs#downloadLink",
+
"sh.comet.v0.feed.defs#buyLink"
+
],
+
"type" => "string"
+
},
+
"value" => %{"format" => "uri", "type" => "string"}
+
},
+
"required" => ["type", "value"],
+
"type" => "object"
+
},
+
"viewerState" => %{
+
"description" =>
+
"Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests.",
+
"properties" => %{
+
"featured" => %{"type" => "boolean"},
+
"like" => %{"format" => "at-uri", "type" => "string"},
+
"repost" => %{"format" => "at-uri", "type" => "string"}
+
},
+
"type" => "object"
+
}
+
},
+
"id" => "sh.comet.v0.feed.defs",
+
"lexicon" => 1
+
})
+
end
+45
lib/atproto/sh/comet/v0/feed/getActorTracks.ex
···
···
+
defmodule Sh.Comet.V0.Feed.GetActorTracks do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Get a list of an actor's tracks.",
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"cursor" => %{"type" => "string"},
+
"tracks" => %{
+
"items" => %{
+
"ref" => "sh.comet.v0.feed.track#view",
+
"type" => "ref"
+
},
+
"type" => "array"
+
}
+
},
+
"required" => ["tracks"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"actor" => %{"format" => "at-identifier", "type" => "string"},
+
"cursor" => %{"type" => "string"},
+
"limit" => %{
+
"default" => 50,
+
"maximum" => 100,
+
"minimum" => 1,
+
"type" => "integer"
+
}
+
},
+
"required" => ["actor"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "sh.comet.v0.feed.getActorTracks",
+
"lexicon" => 1
+
})
+
end
+189
lib/atproto/sh/comet/v0/feed/track.ex
···
···
+
defmodule Sh.Comet.V0.Feed.Track do
+
@moduledoc """
+
The following `deflexicon` call should result in something similar to the following output:
+
+
import Peri
+
import Atex.Lexicon.Validators
+
+
@type main() :: %{}
+
+
"""
+
use Atex.Lexicon
+
# import Atex.Lexicon
+
# import Atex.Lexicon.Validators
+
# import Peri
+
+
# TODO: need an example with `nullable` fields to demonstrate how those are handled (and also the weird extra types in lexicon defs like union)
+
+
@type main() :: %{
+
required(:audio) => Atex.Lexicon.Validators.blob_t(),
+
required(:title) => String.t(),
+
required(:createdAt) => String.t(),
+
# TODO: check if peri replaces with `nil` or omits them completely.
+
optional(:description) => String.t(),
+
optional(:descriptionFacets) => Sh.Comet.V0.Richtext.Facet.main(),
+
optional(:explicit) => boolean(),
+
optional(:image) => Atex.Lexicon.Validators.blob_t(),
+
optional(:link) => Sh.Comet.V0.Feed.Defs.link(),
+
optional(:releasedAt) => String.t(),
+
optional(:tags) => list(String.t())
+
}
+
+
@type view() :: %{
+
required(:uri) => String.t(),
+
required(:cid) => String.t(),
+
required(:author) => Sh.Comet.V0.Actor.Profile.viewFull(),
+
required(:audio) => String.t(),
+
required(:record) => main(),
+
required(:indexedAt) => String.t(),
+
optional(:image) => String.t(),
+
optional(:commentCount) => integer(),
+
optional(:likeCount) => integer(),
+
optional(:playCount) => integer(),
+
optional(:repostCount) => integer(),
+
optional(:viewer) => Sh.Comet.V0.Feed.Defs.viewerState()
+
}
+
+
# Should probably be a separate validator for all rkey formats.
+
# defschema :main_rkey, string(format: :tid)
+
+
# defschema :main, %{
+
# audio: {:required, blob(accept: ["audio/ogg"], max_size: 100_000_000)},
+
# title: {:required, string(min_length: 1, max_length: 2560, max_graphemes: 256)},
+
# createdAt: {:required, string(format: :datetime)},
+
# description: string(max_length: 20000, max_graphemes: 2000),
+
# # This is `ref`
+
# descriptionFacets: Sh.Comet.V0.Richtext.Facet.get_schema(:main),
+
# explicit: :boolean,
+
# image: blob(accept: ["image/png", "image/jpeg"], max_size: 1_000_000),
+
# link: Sh.Comet.V0.Feed.Defs.get_schema(:link),
+
# releasedAt: string(format: :datetime),
+
# tags: array(string(max_graphemes: 64, max_length: 640), max_length: 8)
+
# }
+
+
# defschema :view, %{
+
# uri: {:required, string(format: :at_uri)},
+
# cid: {:required, string(format: :cid)},
+
# author: {:required, Sh.Comet.V0.Actor.Profile.get_schema(:viewFull)},
+
# audio: {:required, string(format: :uri)},
+
# record: {:required, get_schema(:main)},
+
# indexedAt: {:required, string(format: :datetime)},
+
# image: string(format: :uri),
+
# commentCount: :integer,
+
# likeCount: :integer,
+
# playCount: :integer,
+
# repostCount: :integer,
+
# viewer: Sh.Comet.V0.Feed.Defs.get_schema(:viewerState)
+
# }
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"A Comet audio track. TODO: should probably have some sort of pre-calculated waveform, or have a query to get one from a blob?",
+
"key" => "tid",
+
"record" => %{
+
"properties" => %{
+
"audio" => %{
+
"accept" => ["audio/ogg"],
+
"description" =>
+
"Audio of the track, ideally encoded as 96k Opus. Limited to 100mb.",
+
"maxSize" => 100_000_000,
+
"type" => "blob"
+
},
+
"createdAt" => %{
+
"description" => "Timestamp for when the track entry was originally created.",
+
"format" => "datetime",
+
"type" => "string"
+
},
+
"description" => %{
+
"description" => "Description of the track.",
+
"maxGraphemes" => 2000,
+
"maxLength" => 20000,
+
"type" => "string"
+
},
+
"descriptionFacets" => %{
+
"description" => "Annotations of the track's description.",
+
"ref" => "sh.comet.v0.richtext.facet",
+
"type" => "ref"
+
},
+
"explicit" => %{
+
"description" =>
+
"Whether the track contains explicit content that may objectionable to some people, usually swearing or adult themes.",
+
"type" => "boolean"
+
},
+
"image" => %{
+
"accept" => ["image/png", "image/jpeg"],
+
"description" => "Image to be displayed representing the track.",
+
"maxSize" => 1_000_000,
+
"type" => "blob"
+
},
+
"link" => %{"ref" => "sh.comet.v0.feed.defs#link", "type" => "ref"},
+
"releasedAt" => %{
+
"description" =>
+
"Timestamp for when the track was released. If in the future, may be used to implement pre-savable tracks.",
+
"format" => "datetime",
+
"type" => "string"
+
},
+
"tags" => %{
+
"description" => "Hashtags for the track, usually for genres.",
+
"items" => %{
+
"maxGraphemes" => 64,
+
"maxLength" => 640,
+
"type" => "string"
+
},
+
"maxLength" => 8,
+
"type" => "array"
+
},
+
"title" => %{
+
"description" =>
+
"Title of the track. Usually shouldn't include the creator's name.",
+
"maxGraphemes" => 256,
+
"maxLength" => 2560,
+
"minLength" => 1,
+
"type" => "string"
+
}
+
},
+
"required" => ["audio", "title", "createdAt"],
+
"type" => "object"
+
},
+
"type" => "record"
+
},
+
"view" => %{
+
"properties" => %{
+
"audio" => %{
+
"description" =>
+
"URL pointing to where the audio data for the track can be fetched. May be re-encoded from the original blob.",
+
"format" => "uri",
+
"type" => "string"
+
},
+
"author" => %{
+
"ref" => "sh.comet.v0.actor.profile#viewFull",
+
"type" => "ref"
+
},
+
"cid" => %{"format" => "cid", "type" => "string"},
+
"commentCount" => %{"type" => "integer"},
+
"image" => %{
+
"description" => "URL pointing to where the image for the track can be fetched.",
+
"format" => "uri",
+
"type" => "string"
+
},
+
"indexedAt" => %{"format" => "datetime", "type" => "string"},
+
"likeCount" => %{"type" => "integer"},
+
"playCount" => %{"type" => "integer"},
+
"record" => %{"ref" => "#main", "type" => "ref"},
+
"repostCount" => %{"type" => "integer"},
+
"uri" => %{"format" => "at-uri", "type" => "string"},
+
"viewer" => %{
+
"ref" => "sh.comet.v0.feed.defs#viewerState",
+
"type" => "ref"
+
}
+
},
+
"required" => ["uri", "cid", "author", "audio", "record", "indexedAt"],
+
"type" => "object"
+
}
+
},
+
"id" => "sh.comet.v0.feed.track",
+
"lexicon" => 1
+
})
+
end
+70
lib/atproto/sh/comet/v0/richtext/facet.ex
···
···
+
defmodule Sh.Comet.V0.Richtext.Facet do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"byteSlice" => %{
+
"description" =>
+
"Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.",
+
"properties" => %{
+
"byteEnd" => %{"minimum" => 0, "type" => "integer"},
+
"byteStart" => %{"minimum" => 0, "type" => "integer"}
+
},
+
"required" => ["byteStart", "byteEnd"],
+
"type" => "object"
+
},
+
"link" => %{
+
"description" =>
+
"Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.",
+
"properties" => %{"uri" => %{"format" => "uri", "type" => "string"}},
+
"required" => ["uri"],
+
"type" => "object"
+
},
+
"main" => %{
+
"description" => "Annotation of a sub-string within rich text.",
+
"properties" => %{
+
"features" => %{
+
"items" => %{
+
"refs" => ["#mention", "#link", "#tag"],
+
"type" => "union"
+
},
+
"type" => "array"
+
},
+
"index" => %{"ref" => "#byteSlice", "type" => "ref"}
+
},
+
"required" => ["index", "features"],
+
"type" => "object"
+
},
+
"mention" => %{
+
"description" =>
+
"Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID.",
+
"properties" => %{"did" => %{"format" => "did", "type" => "string"}},
+
"required" => ["did"],
+
"type" => "object"
+
},
+
"tag" => %{
+
"description" =>
+
"Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags').",
+
"properties" => %{
+
"tag" => %{"maxGraphemes" => 64, "maxLength" => 640, "type" => "string"}
+
},
+
"required" => ["tag"],
+
"type" => "object"
+
},
+
"timestamp" => %{
+
"description" =>
+
"Facet feature for a timestamp in a track. The text usually is in the format of 'hh:mm:ss' with the hour section being omitted if unnecessary.",
+
"properties" => %{
+
"timestamp" => %{
+
"description" => "Reference time, in seconds.",
+
"minimum" => 0,
+
"type" => "integer"
+
}
+
},
+
"type" => "object"
+
}
+
},
+
"id" => "sh.comet.v0.richtext.facet",
+
"lexicon" => 1
+
})
+
end
+1 -1
mix.exs
···
defp deps do
[
-
{:peri, "~> 0.5"},
{:multiformats_ex, "~> 0.2"},
{:recase, "~> 0.5"},
{:req, "~> 0.5"},
···
defp deps do
[
+
{:peri, "~> 0.6"},
{:multiformats_ex, "~> 0.2"},
{:recase, "~> 0.5"},
{:req, "~> 0.5"},
+1 -1
mix.lock
···
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
-
"peri": {:hex, :peri, "0.5.1", "2140fd94095282aea1435c98307f25dde42005d319abb9927179301c310619c1", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "c214590d3bdf9d0e5f6d36df1cc87d956b7625c9ba32ca786983ba6df1936be3"},
"recase": {:hex, :recase, "0.8.1", "ab98cd35857a86fa5ca99036f575241d71d77d9c2ab0c39aacf1c9b61f6f7d1d", [:mix], [], "hexpm", "9fd8d63e7e43bd9ea385b12364e305778b2bbd92537e95c4b2e26fc507d5e4c2"},
"req": {:hex, :req, "0.5.12", "7ce85835867a114c28b6cfc2d8a412f86660290907315ceb173a00e587b853d2", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d65f3d0e7032eb245706554cb5240dbe7a07493154e2dd34e7bb65001aa6ef32"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
···
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
+
"peri": {:hex, :peri, "0.6.0", "0758aa037f862f7a3aa0823cb82195916f61a8071f6eaabcff02103558e61a70", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "b27f118f3317fbc357c4a04b3f3c98561efdd8865edd4ec0e24fd936c7ff36c8"},
"recase": {:hex, :recase, "0.8.1", "ab98cd35857a86fa5ca99036f575241d71d77d9c2ab0c39aacf1c9b61f6f7d1d", [:mix], [], "hexpm", "9fd8d63e7e43bd9ea385b12364e305778b2bbd92537e95c4b2e26fc507d5e4c2"},
"req": {:hex, :req, "0.5.12", "7ce85835867a114c28b6cfc2d8a412f86660290907315ceb173a00e587b853d2", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d65f3d0e7032eb245706554cb5240dbe7a07493154e2dd34e7bb65001aa6ef32"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},