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

feat(lexicons): add structs for queries and procedures

ovyerus.com 75981faa 6c7c464a

verified
Changed files
+120 -68
lib
atex
lexicon
validators
+99 -24
lib/atex/lexicon.ex
···
end
end)
-
foo =
-
quote do
-
def id, do: unquote(lexicon_id)
-
-
unquote_splicing(defs)
-
end
+
quote do
+
def id, do: unquote(lexicon_id)
-
if lexicon.id == "app.bsky.feed.post" do
-
IO.puts("-----")
-
foo |> Macro.expand(__ENV__) |> Macro.to_string() |> IO.puts()
+
unquote_splicing(defs)
end
-
-
foo
end
-
# For records and objects:
-
# - [x] `main` is in core module, otherwise nested with its name (should probably be handled above instead of in `def_to_schema`, like expanding typespecs)
-
# - [x] Define all keys in the schema, `@enforce`ing non-nullable/required fields
-
# - [x] `$type` field with the full NSID
-
# - [x] Custom JSON encoder function that omits optional fields that are `nil`, due to different semantics
-
# - [ ] Add `$type` to schema but make it optional - allowing unbranded types through, but mismatching brand will fail.
# - [ ] `t()` type should be the struct in it. (add to non-main structs too?)
@spec def_to_schema(nsid :: String.t(), def_name :: String.t(), lexicon_def :: map()) ::
···
defp def_to_schema(nsid, def_name, %{type: "record", record: record}) do
# TODO: record rkey format validator
+
type_name = Atex.NSID.canonical_name(nsid, to_string(def_name))
+
+
record =
+
put_in(record, [:properties, :"$type"], %{
+
type: "string",
+
const: type_name,
+
default: type_name
+
})
+
def_to_schema(nsid, def_name, record)
end
-
# TODO: need to spit out an extra 'branded' type with `$type` field, for use in union refs.
+
# TODO: add struct to types
defp def_to_schema(
nsid,
def_name,
···
end)
struct_keys =
-
Enum.map(properties, fn
+
properties
+
|> Enum.filter(fn {key, _} -> key !== :"$type" end)
+
|> Enum.map(fn
{key, %{default: default}} -> {key, default}
{key, _field} -> {key, nil}
-
end) ++ [{:"$type", if(def_name == :main, do: nsid, else: "#{nsid}##{def_name}")}]
+
end)
+
|> then(&(&1 ++ [{:"$type", if(def_name == :main, do: nsid, else: "#{nsid}##{def_name}")}]))
-
enforced_keys = properties |> Map.keys() |> Enum.filter(&(to_string(&1) in required))
+
enforced_keys =
+
properties |> Map.keys() |> Enum.filter(&(to_string(&1) in required && &1 != :"$type"))
optional_if_nil_keys =
properties
···
|> Enum.filter(fn key ->
key = to_string(key)
# TODO: what if it is nullable but not required?
-
key not in required && key not in nullable
+
key not in required && key not in nullable && key != "$type"
end)
quoted_struct =
···
schema
end
-
[params, output]
+
# Root struct containing `params`
+
main =
+
if params do
+
{
+
:main,
+
nil,
+
quote do
+
%__MODULE__{params: params()}
+
end,
+
quote do
+
@enforce_keys [:params]
+
defstruct params: nil
+
end
+
}
+
else
+
{
+
:main,
+
nil,
+
quote do
+
%__MODULE__{}
+
end,
+
quote do
+
defstruct []
+
end
+
}
+
end
+
+
[main, params, output]
|> Enum.reject(&is_nil/1)
end
···
schema
end
-
[params, output, input]
+
# Root struct containing `input`, `raw_input`, and `params`
+
main =
+
{
+
:main,
+
nil,
+
cond do
+
params && input ->
+
quote do
+
%__MODULE__{input: input(), params: params()}
+
end
+
+
input ->
+
quote do
+
%__MODULE__{input: input()}
+
end
+
+
params ->
+
quote do
+
%__MODULE__{raw_input: any(), params: params()}
+
end
+
+
true ->
+
quote do
+
%__MODULE__{raw_input: any()}
+
end
+
end,
+
cond do
+
params && input ->
+
quote do
+
defstruct input: nil, params: nil
+
end
+
+
input ->
+
quote do
+
defstruct input: nil
+
end
+
+
params ->
+
quote do
+
defstruct raw_input: nil, params: nil
+
end
+
+
true ->
+
quote do
+
defstruct raw_input: nil
+
end
+
end
+
}
+
+
[main, params, output, input]
|> Enum.reject(&is_nil/1)
end
+12 -44
lib/atex/lexicon/validators/string.ex
···
defmodule Atex.Lexicon.Validators.String do
alias Atex.Lexicon.Validators
-
@type format() ::
-
:at_identifier
-
| :at_uri
-
| :cid
-
| :datetime
-
| :did
-
| :handle
-
| :nsid
-
| :tid
-
| :record_key
-
| :uri
-
| :language
-
@type option() ::
-
{:format, format()}
+
{:format, String.t()}
| {:min_length, non_neg_integer()}
| {:max_length, non_neg_integer()}
| {:min_graphemes, non_neg_integer()}
···
@record_key_re ~r"^[a-zA-Z0-9.-_:~]$"
-
# TODO: probably should go into a different module, one with general lexicon -> validator gen conversions
-
@spec format_to_atom(String.t()) :: format()
-
def format_to_atom(format) do
-
case format do
-
"at-identifier" -> :at_identifier
-
"at-uri" -> :at_uri
-
"cid" -> :cid
-
"datetime" -> :datetime
-
"did" -> :did
-
"handle" -> :handle
-
"nsid" -> :nsid
-
"tid" -> :tid
-
"record-key" -> :record_key
-
"uri" -> :uri
-
"language" -> :language
-
_ -> raise "Unknown lexicon string format `#{format}`"
-
end
-
end
-
@spec validate(term(), list(option())) :: Peri.validation_result()
def validate(value, options) when is_binary(value) do
options
···
defp validate_option(_value, {option, nil}) when option in @option_keys, do: :ok
-
defp validate_option(value, {:format, :at_identifier}),
+
defp validate_option(value, {:format, "at-identifier"}),
do:
Validators.boolean_validate(
Atex.DID.match?(value) or Atex.Handle.match?(value),
"should be a valid DID or handle"
)
-
defp validate_option(value, {:format, :at_uri}),
+
defp validate_option(value, {:format, "at-uri"}),
do: Validators.boolean_validate(Atex.AtURI.match?(value), "should be a valid at:// URI")
-
defp validate_option(value, {:format, :cid}) do
+
defp validate_option(value, {:format, "cid"}) do
# TODO: is there a regex provided by the lexicon docs/somewhere?
try do
Multiformats.CID.decode(value)
···
end
end
-
defp validate_option(value, {:format, :datetime}) do
+
defp validate_option(value, {:format, "datetime"}) do
# NaiveDateTime is used over DateTime because the result isn't actually
# being used, so we don't need to include a calendar library just for this.
case NaiveDateTime.from_iso8601(value) do
···
end
end
-
defp validate_option(value, {:format, :did}),
+
defp validate_option(value, {:format, "did"}),
do: Validators.boolean_validate(Atex.DID.match?(value), "should be a valid DID")
-
defp validate_option(value, {:format, :handle}),
+
defp validate_option(value, {:format, "handle"}),
do: Validators.boolean_validate(Atex.Handle.match?(value), "should be a valid handle")
-
defp validate_option(value, {:format, :nsid}),
+
defp validate_option(value, {:format, "nsid"}),
do: Validators.boolean_validate(Atex.NSID.match?(value), "should be a valid NSID")
-
defp validate_option(value, {:format, :tid}),
+
defp validate_option(value, {:format, "tid"}),
do: Validators.boolean_validate(Atex.TID.match?(value), "should be a valid TID")
-
defp validate_option(value, {:format, :record_key}),
+
defp validate_option(value, {:format, "record-key"}),
do:
Validators.boolean_validate(
Regex.match?(@record_key_re, value),
"should be a valid record key"
)
-
defp validate_option(value, {:format, :uri}) do
+
defp validate_option(value, {:format, "uri"}) do
case URI.new(value) do
{:ok, _} -> :ok
{:error, _} -> {:error, "should be a valid URI", []}
end
end
-
defp validate_option(value, {:format, :language}) do
+
defp validate_option(value, {:format, "language"}) do
case Cldr.LanguageTag.parse(value) do
{:ok, _} -> :ok
{:error, _} -> {:error, "should be a valid BCP 47 language tag", []}
+9
lib/atex/nsid.ex
···
possible_fragment
end
end
+
+
@spec canonical_name(String.t(), String.t()) :: String.t()
+
def canonical_name(nsid, fragment) do
+
if fragment == "main" do
+
nsid
+
else
+
"#{nsid}##{fragment}"
+
end
+
end
end