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

feat(deflexicon): generate typespecs

ovyerus.com ee40feb3 9e142f68

verified
Changed files
+146 -112
lib
atex
atproto
sh
comet
v0
feed
+141 -36
lib/atex/lexicon.ex
···
|> 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} ->
+
|> Enum.map(fn {schema_key, quoted_schema, quoted_type} ->
+
identity_type =
+
if schema_key === :main do
+
quote do
+
@type t() :: unquote(quoted_type)
+
end
+
end
+
quote do
+
@type unquote(schema_key)() :: unquote(quoted_type)
+
unquote(identity_type)
+
defschema unquote(schema_key), unquote(quoted_schema)
end
end)
···
end
end
-
# TODO: generate typedefs
@spec def_to_schema(nsid :: String.t(), def_name :: String.t(), lexicon_def :: map()) ::
-
list({key :: atom(), quoted :: term()})
+
list({key :: atom(), quoted_schema :: term(), quoted_type :: 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
+
# TODO: add `$type` field. It's just a string though.
defp def_to_schema(
nsid,
def_name,
···
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})
+
{quoted_schema, quoted_type} = field_to_schema(field, nsid)
+
is_nullable = key in nullable
+
is_required = key in required
+
+
quoted_schema =
+
quoted_schema
+
|> then(
+
&if is_nullable, do: quote(do: {:either, {{:literal, nil}, unquote(&1)}}), else: &1
+
)
+
|> then(&if is_required, do: quote(do: {:required, unquote(&1)}), else: &1)
+
|> then(&{key, &1})
+
+
key_type = if is_required, do: :required, else: :optional
+
+
quoted_type =
+
quoted_type
+
|> then(
+
&if is_nullable do
+
{:|, [], [&1, nil]}
+
else
+
&1
+
end
+
)
+
|> then(&{{key_type, [], [key]}, &1})
+
+
{quoted_schema, quoted_type}
+
end)
+
|> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} ->
+
{[quoted_schema | schemas], [quoted_type | types]}
end)
-
|> then(&{:%{}, [], &1})
-
|> then(&[{atomise(def_name), &1}])
+
|> then(fn {quoted_schemas, quoted_types} ->
+
[{atomise(def_name), {:%{}, [], quoted_schemas}, {:%{}, [], quoted_types}}]
+
end)
end
# TODO: validating errors?
···
defp def_to_schema(_nsid, def_name, %{type: "token"}) do
# TODO: make it a validator that expects the nsid + key.
-
[{atomise(def_name), :string}]
+
[
+
{
+
atomise(def_name),
+
:string,
+
quote do
+
String.t()
+
end
+
}
+
]
end
defp def_to_schema(nsid, def_name, %{type: type} = def)
···
"cid-link",
"unknown"
] do
-
[{atomise(def_name), field_to_schema(def, nsid)}]
+
{quoted_schema, quoted_type} = field_to_schema(def, nsid)
+
[{atomise(def_name), quoted_schema, quoted_type}]
end
-
@spec field_to_schema(field_def :: %{type: String.t()}, nsid :: String.t()) :: Peri.schema_def()
+
@spec field_to_schema(field_def :: %{type: String.t()}, nsid :: String.t()) ::
+
{quoted_schema :: term(), quoted_typespec :: term()}
defp field_to_schema(%{type: "string"} = field, _nsid) do
fixed_schema = const_or_enum(field)
···
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
|> then(&{:custom, {Validators.String, :validate, [&1]}})
|> maybe_default(field)
-
|> then(&Macro.escape/1)
+
|> then(
+
&{Macro.escape(&1),
+
quote do
+
String.t()
+
end}
+
)
end
end
defp field_to_schema(%{type: "boolean"} = field, _nsid) do
(const(field) || :boolean)
|> maybe_default(field)
-
|> then(&Macro.escape/1)
+
|> then(
+
&{Macro.escape(&1),
+
quote do
+
boolean()
+
end}
+
)
end
defp field_to_schema(%{type: "integer"} = field, _nsid) do
···
|> then(&{:custom, {Validators.Integer, [&1]}})
|> maybe_default(field)
end
-
|> then(&Macro.escape/1)
+
|> then(
+
&{
+
Macro.escape(&1),
+
# TODO: turn into range definition based on maximum/minimum
+
quote do
+
integer()
+
end
+
}
+
)
end
defp field_to_schema(%{type: "array", items: items} = field, nsid) do
-
inner_schema = field_to_schema(items, nsid)
+
{inner_schema, inner_type} = field_to_schema(items, nsid)
field
|> Map.take([:maxLength, :minLength])
···
{inner_schema, _} = Code.eval_quoted(quoted_inner_schema)
{:custom, {:{}, c, [Validators.Array, :validate, [inner_schema | args]]}}
end)
+
|> then(
+
&{&1,
+
quote do
+
list(unquote(inner_type))
+
end}
+
)
end
defp field_to_schema(%{type: "blob"} = field, _nsid) do
···
|> Map.take([:accept, :maxSize])
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
|> Validators.blob()
-
|> then(&Macro.escape/1)
+
|> then(
+
&{Macro.escape(&1),
+
quote do
+
Validators.blob()
+
end}
+
)
end
defp field_to_schema(%{type: "bytes"} = field, _nsid) do
···
|> Map.take([:maxLength, :minLength])
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
|> Validators.bytes()
-
|> then(&Macro.escape/1)
+
|> then(
+
&{Macro.escape(&1),
+
quote do
+
Validators.bytes()
+
end}
+
)
end
defp field_to_schema(%{type: "cid-link"}, _nsid) do
Validators.cid_link()
-
|> then(&Macro.escape/1)
+
|> then(
+
&{Macro.escape(&1),
+
quote do
+
Validators.cid_link()
+
end}
+
)
end
# TODO: do i need to make sure these two deal with brands? Check objects in atp.tools
···
|> Atex.NSID.expand_possible_fragment_shorthand(ref)
|> Atex.NSID.to_atom_with_fragment()
-
quote do
-
unquote(nsid).get_schema(unquote(fragment))
-
end
+
{quote do
+
unquote(nsid).get_schema(unquote(fragment))
+
end,
+
quote do
+
unquote(nsid).unquote(fragment)()
+
end}
end
defp field_to_schema(%{type: "union", refs: refs}, nsid) do
-
# refs =
refs
|> Enum.map(fn ref ->
{nsid, fragment} =
···
|> Atex.NSID.expand_possible_fragment_shorthand(ref)
|> Atex.NSID.to_atom_with_fragment()
-
quote do
-
unquote(nsid).get_schema(unquote(fragment))
-
end
+
{quote do
+
unquote(nsid).get_schema(unquote(fragment))
+
end,
+
quote do
+
unquote(nsid).unquote(fragment)()
+
end}
end)
-
|> then(
-
&quote do
-
{:oneof, unquote(&1)}
-
end
-
)
+
|> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} ->
+
{[quoted_schema | schemas], [quoted_type | types]}
+
end)
+
|> then(fn {schemaa, types} ->
+
{quote do
+
{:oneof, unquote(schemaa)}
+
end,
+
quote do
+
unquote(join_with_pipe(types))
+
end}
+
end)
end
# TODO: apparently should be a data object, not a primitive?
defp field_to_schema(%{type: "unknown"}, _nsid) do
-
:any
+
{:any,
+
quote do
+
term()
+
end}
end
-
defp field_to_schema(_field_def, _nsid), do: nil
+
defp field_to_schema(_field_def, _nsid), do: {nil, nil}
defp maybe_default(schema, field) do
if field[:default] != nil,
···
defp atomise(x) when is_atom(x), do: x
defp atomise(x) when is_binary(x), do: String.to_atom(x)
+
+
defp join_with_pipe(list) when is_list(list) do
+
[piped] = do_join_with_pipe(list)
+
piped
+
end
+
+
defp do_join_with_pipe([head]), do: [head]
+
defp do_join_with_pipe([head | tail]), do: [{:|, [], [head | do_join_with_pipe(tail)]}]
+
defp do_join_with_pipe([]), do: []
end
+5 -1
lib/atex/lexicon/validators.ex
···
@type blob_option() :: {:accept, list(String.t())} | {:max_size, pos_integer()}
-
@type blob_t() ::
+
@type blob() ::
%{
"$type": String.t(),
ref: %{"$link": String.t()},
···
cid: String.t(),
mimeType: String.t()
}
+
+
@type cid_link() :: %{"$link": String.t()}
+
+
@type bytes() :: %{"$bytes": binary()}
@spec string(list(Validators.String.option())) :: Peri.custom_def()
def string(options \\ []), do: {:custom, {Validators.String, :validate, [options]}}
-75
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" => %{