diff --git a/.formatter.exs b/.formatter.exs index 0475294..b194eaf 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,8 @@ # Used by "mix format" [ inputs: ["{mix,.formatter,.credo}.exs", "{config,lib,test}/**/*.{ex,exs}"], - import_deps: [:typedstruct, :peri] + import_deps: [:typedstruct, :peri], + export: [ + locals_without_parens: [deflexicon: 1] + ] ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 524b947..ee3dc6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 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 diff --git a/lib/atex/lexicon.ex b/lib/atex/lexicon.ex new file mode 100644 index 0000000..c9f0374 --- /dev/null +++ b/lib/atex/lexicon.ex @@ -0,0 +1,309 @@ +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( + "e 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 diff --git a/lib/atex/lexicon/schema.ex b/lib/atex/lexicon/schema.ex new file mode 100644 index 0000000..92eaca6 --- /dev/null +++ b/lib/atex/lexicon/schema.ex @@ -0,0 +1,265 @@ +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 diff --git a/lib/atex/lexicon/validators.ex b/lib/atex/lexicon/validators.ex index 1c8cb65..290593e 100644 --- a/lib/atex/lexicon/validators.ex +++ b/lib/atex/lexicon/validators.ex @@ -1,16 +1,19 @@ defmodule Atex.Lexicon.Validators do alias Atex.Lexicon.Validators - @type blob_option() :: {:accept, list(String.t())} | {:max_size, integer()} + @type blob_option() :: {:accept, list(String.t())} | {:max_size, pos_integer()} @type blob_t() :: %{ "$type": String.t(), - req: %{"$link": 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]}} @@ -20,7 +23,6 @@ defmodule Atex.Lexicon.Validators do @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 @@ -49,13 +51,32 @@ defmodule Atex.Lexicon.Validators do }, # Old deprecated blobs %{ - cid: {:reqiured, :string}, + 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 + @spec boolean_validate(boolean(), String.t(), keyword() | map()) :: Peri.validation_result() def boolean_validate(success?, error_message, context \\ []) do diff --git a/lib/atex/lexicon/validators/bytes.ex b/lib/atex/lexicon/validators/bytes.ex new file mode 100644 index 0000000..0f5420b --- /dev/null +++ b/lib/atex/lexicon/validators/bytes.ex @@ -0,0 +1,32 @@ +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 diff --git a/lib/atex/lexicon/validators/integer.ex b/lib/atex/lexicon/validators/integer.ex index c2efb13..cda0926 100644 --- a/lib/atex/lexicon/validators/integer.ex +++ b/lib/atex/lexicon/validators/integer.ex @@ -4,19 +4,15 @@ defmodule Atex.Lexicon.Validators.Integer do @type option() :: {:minimum, integer()} | {:maximum, integer()} - | {:enum, list(integer())} - | {:const, integer()} - @option_keys [:minimum, :maximum, :enum, :const] + @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, - enum: nil, - const: nil + maximum: nil ) |> Stream.map(&validate_option(value, &1)) |> Enum.find(:ok, fn x -> x != :ok end) @@ -41,15 +37,4 @@ defmodule Atex.Lexicon.Validators.Integer do 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 diff --git a/lib/atex/lexicon/validators/string.ex b/lib/atex/lexicon/validators/string.ex index 3391bcd..7d1f417 100644 --- a/lib/atex/lexicon/validators/string.ex +++ b/lib/atex/lexicon/validators/string.ex @@ -20,17 +20,13 @@ defmodule Atex.Lexicon.Validators.String do | {: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 + :max_graphemes ] @record_key_re ~r"^[a-zA-Z0-9.-_:~]$" @@ -62,9 +58,7 @@ defmodule Atex.Lexicon.Validators.String do min_length: nil, max_length: nil, min_graphemes: nil, - max_graphemes: nil, - enum: nil, - const: nil + max_graphemes: nil ) # Stream so we early exit at the first error. |> Stream.map(&validate_option(value, &1)) @@ -168,15 +162,4 @@ defmodule Atex.Lexicon.Validators.String do "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 diff --git a/lib/atex/nsid.ex b/lib/atex/nsid.ex index c86a30e..98bb78c 100644 --- a/lib/atex/nsid.ex +++ b/lib/atex/nsid.ex @@ -9,4 +9,33 @@ defmodule Atex.NSID do # 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 diff --git a/lib/atex/peri.ex b/lib/atex/peri.ex index 45abd56..5d7c517 100644 --- a/lib/atex/peri.ex +++ b/lib/atex/peri.ex @@ -14,4 +14,27 @@ defmodule Atex.Peri do 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 diff --git a/lib/atproto/sh/comet/v0/actor/profile.ex b/lib/atproto/sh/comet/v0/actor/profile.ex new file mode 100644 index 0000000..151ef9e --- /dev/null +++ b/lib/atproto/sh/comet/v0/actor/profile.ex @@ -0,0 +1,115 @@ +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 diff --git a/lib/atproto/sh/comet/v0/feed/defs.ex b/lib/atproto/sh/comet/v0/feed/defs.ex new file mode 100644 index 0000000..653244e --- /dev/null +++ b/lib/atproto/sh/comet/v0/feed/defs.ex @@ -0,0 +1,44 @@ +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 diff --git a/lib/atproto/sh/comet/v0/feed/getActorTracks.ex b/lib/atproto/sh/comet/v0/feed/getActorTracks.ex new file mode 100644 index 0000000..944679b --- /dev/null +++ b/lib/atproto/sh/comet/v0/feed/getActorTracks.ex @@ -0,0 +1,45 @@ +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 diff --git a/lib/atproto/sh/comet/v0/feed/track.ex b/lib/atproto/sh/comet/v0/feed/track.ex new file mode 100644 index 0000000..1739a52 --- /dev/null +++ b/lib/atproto/sh/comet/v0/feed/track.ex @@ -0,0 +1,189 @@ +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 diff --git a/lib/atproto/sh/comet/v0/richtext/facet.ex b/lib/atproto/sh/comet/v0/richtext/facet.ex new file mode 100644 index 0000000..232140b --- /dev/null +++ b/lib/atproto/sh/comet/v0/richtext/facet.ex @@ -0,0 +1,70 @@ +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 diff --git a/mix.exs b/mix.exs index 71857b6..071e72f 100644 --- a/mix.exs +++ b/mix.exs @@ -27,7 +27,7 @@ defmodule Atex.MixProject do defp deps do [ - {:peri, "~> 0.5"}, + {:peri, "~> 0.6"}, {:multiformats_ex, "~> 0.2"}, {:recase, "~> 0.5"}, {:req, "~> 0.5"}, diff --git a/mix.lock b/mix.lock index 477dded..caf7bd6 100644 --- a/mix.lock +++ b/mix.lock @@ -19,7 +19,7 @@ "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"}, + "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"},