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
··· 1 # Used by "mix format" 2 [ 3 inputs: ["{mix,.formatter,.credo}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 - import_deps: [:typedstruct, :peri] 5 ]
··· 1 # Used by "mix format" 2 [ 3 inputs: ["{mix,.formatter,.credo}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 + import_deps: [:typedstruct, :peri], 5 + export: [ 6 + locals_without_parens: [deflexicon: 1] 7 + ] 8 ]
+7 -1
CHANGELOG.md
··· 6 and this project adheres to 7 [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 8 9 - <!-- ## [Unreleased] --> 10 11 ## [0.3.0] - 2025-06-29 12
··· 6 and this project adheres to 7 [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 8 9 + ## [Unreleased] 10 + 11 + ### Added 12 + 13 + - `Atex.Lexicon` module that provides the `deflexicon` macro, taking in a JSON 14 + Lexicon definition and converts it into a series of schemas for each 15 + definition within it. 16 17 ## [0.3.0] - 2025-06-29 18
+309
lib/atex/lexicon.ex
···
··· 1 + defmodule Atex.Lexicon do 2 + @moduledoc """ 3 + Provide `deflexicon` macro for defining a module with types and schemas from an entire lexicon definition. 4 + 5 + Should it also define structs, with functions to convert from input case to snake case? 6 + """ 7 + 8 + alias Atex.Lexicon.Validators 9 + 10 + defmacro __using__(_opts) do 11 + quote do 12 + import Atex.Lexicon 13 + import Atex.Lexicon.Validators 14 + import Peri 15 + end 16 + end 17 + 18 + defmacro deflexicon(lexicon) do 19 + # Better way to get the real map, without having to eval? (custom function to compose one from quoted?) 20 + lexicon = 21 + lexicon 22 + |> Code.eval_quoted() 23 + |> elem(0) 24 + |> then(&Recase.Enumerable.atomize_keys/1) 25 + |> then(&Atex.Lexicon.Schema.lexicon!/1) 26 + 27 + # TODO: support returning typedefs 28 + defs = 29 + lexicon.defs 30 + |> Enum.flat_map(fn {def_name, def} -> def_to_schema(lexicon.id, def_name, def) end) 31 + |> Enum.map(fn {schema_key, quoted_schema} -> 32 + quote do 33 + defschema unquote(schema_key), unquote(quoted_schema) 34 + end 35 + end) 36 + 37 + quote do 38 + def id, do: unquote(Atex.NSID.to_atom(lexicon.id)) 39 + 40 + unquote_splicing(defs) 41 + end 42 + end 43 + 44 + # TODO: generate typedefs 45 + @spec def_to_schema(nsid :: String.t(), def_name :: String.t(), lexicon_def :: map()) :: 46 + list({key :: atom(), quoted :: term()}) 47 + 48 + defp def_to_schema(nsid, def_name, %{type: "record", record: record}) do 49 + # TODO: record rkey format validator 50 + def_to_schema(nsid, def_name, record) 51 + end 52 + 53 + defp def_to_schema( 54 + nsid, 55 + def_name, 56 + %{ 57 + type: "object", 58 + properties: properties, 59 + required: required 60 + } = def 61 + ) do 62 + nullable = Map.get(def, :nullable, []) 63 + 64 + properties 65 + |> Enum.map(fn {key, field} -> 66 + field_to_schema(field, nsid) 67 + |> then( 68 + &if key in nullable, do: quote(do: {:either, {{:literal, nil}, unquote(&1)}}), else: &1 69 + ) 70 + |> then(&if key in required, do: quote(do: {:required, unquote(&1)}), else: &1) 71 + |> then(&{key, &1}) 72 + end) 73 + |> then(&{:%{}, [], &1}) 74 + |> then(&[{atomise(def_name), &1}]) 75 + end 76 + 77 + # TODO: validating errors? 78 + defp def_to_schema(nsid, _def_name, %{type: "query"} = def) do 79 + params = 80 + if def[:parameters] do 81 + [schema] = 82 + def_to_schema(nsid, "params", %{ 83 + type: "object", 84 + required: def.parameters.required, 85 + nullable: [], 86 + properties: def.parameters.properties 87 + }) 88 + 89 + schema 90 + end 91 + 92 + output = 93 + if def.output && def.output.schema do 94 + [schema] = def_to_schema(nsid, "output", def.output.schema) 95 + schema 96 + end 97 + 98 + [params, output] 99 + |> Enum.reject(&is_nil/1) 100 + end 101 + 102 + defp def_to_schema(nsid, _def_name, %{type: "procedure"} = def) do 103 + # TODO: better keys for these 104 + params = 105 + if def[:parameters] do 106 + [schema] = 107 + def_to_schema(nsid, "params", %{ 108 + type: "object", 109 + required: def.parameters.required, 110 + properties: def.parameters.properties 111 + }) 112 + 113 + schema 114 + end 115 + 116 + output = 117 + if def[:output] && def.output.schema do 118 + [schema] = def_to_schema(nsid, "output", def.output.schema) 119 + schema 120 + end 121 + 122 + input = 123 + if def[:input] && def.input.schema do 124 + [schema] = def_to_schema(nsid, "output", def.input.schema) 125 + schema 126 + end 127 + 128 + [params, output, input] 129 + |> Enum.reject(&is_nil/1) 130 + end 131 + 132 + defp def_to_schema(nsid, _def_name, %{type: "subscription"} = def) do 133 + params = 134 + if def[:parameters] do 135 + [schema] = 136 + def_to_schema(nsid, "params", %{ 137 + type: "object", 138 + required: def.parameters.required, 139 + properties: def.parameters.properties 140 + }) 141 + 142 + schema 143 + end 144 + 145 + message = 146 + if def[:message] do 147 + [schema] = def_to_schema(nsid, "message", def.message.schema) 148 + schema 149 + end 150 + 151 + [params, message] 152 + |> Enum.reject(&is_nil/1) 153 + end 154 + 155 + defp def_to_schema(_nsid, def_name, %{type: "token"}) do 156 + # TODO: make it a validator that expects the nsid + key. 157 + [{atomise(def_name), :string}] 158 + end 159 + 160 + defp def_to_schema(nsid, def_name, %{type: type} = def) 161 + when type in [ 162 + "blob", 163 + "array", 164 + "boolean", 165 + "integer", 166 + "string", 167 + "bytes", 168 + "cid-link", 169 + "unknown" 170 + ] do 171 + [{atomise(def_name), field_to_schema(def, nsid)}] 172 + end 173 + 174 + @spec field_to_schema(field_def :: %{type: String.t()}, nsid :: String.t()) :: Peri.schema_def() 175 + defp field_to_schema(%{type: "string"} = field, _nsid) do 176 + fixed_schema = const_or_enum(field) 177 + 178 + if fixed_schema do 179 + maybe_default(fixed_schema, field) 180 + else 181 + field 182 + |> Map.take([ 183 + :format, 184 + :maxLength, 185 + :minLength, 186 + :maxGraphemes, 187 + :minGraphemes 188 + ]) 189 + |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end) 190 + |> then(&{:custom, {Validators.String, :validate, [&1]}}) 191 + |> maybe_default(field) 192 + |> then(&Macro.escape/1) 193 + end 194 + end 195 + 196 + defp field_to_schema(%{type: "boolean"} = field, _nsid) do 197 + (const(field) || :boolean) 198 + |> maybe_default(field) 199 + |> then(&Macro.escape/1) 200 + end 201 + 202 + defp field_to_schema(%{type: "integer"} = field, _nsid) do 203 + fixed_schema = const_or_enum(field) 204 + 205 + if fixed_schema do 206 + maybe_default(fixed_schema, field) 207 + else 208 + field 209 + |> Map.take([:maximum, :minimum]) 210 + |> Keyword.new() 211 + |> then(&{:custom, {Validators.Integer, [&1]}}) 212 + |> maybe_default(field) 213 + end 214 + |> then(&Macro.escape/1) 215 + end 216 + 217 + defp field_to_schema(%{type: "array", items: items} = field, nsid) do 218 + inner_schema = field_to_schema(items, nsid) 219 + 220 + field 221 + |> Map.take([:maxLength, :minLength]) 222 + |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end) 223 + |> then(&Validators.array(inner_schema, &1)) 224 + |> then(&Macro.escape/1) 225 + # Can't unquote the inner_schema beforehand as that would risk evaluating `get_schema`s which don't exist yet. 226 + # There's probably a better way to do this lol. 227 + |> then(fn {:custom, {:{}, c, [Validators.Array, :validate, [quoted_inner_schema | args]]}} -> 228 + {inner_schema, _} = Code.eval_quoted(quoted_inner_schema) 229 + {:custom, {:{}, c, [Validators.Array, :validate, [inner_schema | args]]}} 230 + end) 231 + end 232 + 233 + defp field_to_schema(%{type: "blob"} = field, _nsid) do 234 + field 235 + |> Map.take([:accept, :maxSize]) 236 + |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end) 237 + |> Validators.blob() 238 + |> then(&Macro.escape/1) 239 + end 240 + 241 + defp field_to_schema(%{type: "bytes"} = field, _nsid) do 242 + field 243 + |> Map.take([:maxLength, :minLength]) 244 + |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end) 245 + |> Validators.bytes() 246 + |> then(&Macro.escape/1) 247 + end 248 + 249 + defp field_to_schema(%{type: "cid-link"}, _nsid) do 250 + Validators.cid_link() 251 + |> then(&Macro.escape/1) 252 + end 253 + 254 + # TODO: do i need to make sure these two deal with brands? Check objects in atp.tools 255 + defp field_to_schema(%{type: "ref", ref: ref}, nsid) do 256 + {nsid, fragment} = 257 + nsid 258 + |> Atex.NSID.expand_possible_fragment_shorthand(ref) 259 + |> Atex.NSID.to_atom_with_fragment() 260 + 261 + quote do 262 + unquote(nsid).get_schema(unquote(fragment)) 263 + end 264 + end 265 + 266 + defp field_to_schema(%{type: "union", refs: refs}, nsid) do 267 + # refs = 268 + refs 269 + |> Enum.map(fn ref -> 270 + {nsid, fragment} = 271 + nsid 272 + |> Atex.NSID.expand_possible_fragment_shorthand(ref) 273 + |> Atex.NSID.to_atom_with_fragment() 274 + 275 + quote do 276 + unquote(nsid).get_schema(unquote(fragment)) 277 + end 278 + end) 279 + |> then( 280 + &quote do 281 + {:oneof, unquote(&1)} 282 + end 283 + ) 284 + end 285 + 286 + # TODO: apparently should be a data object, not a primitive? 287 + defp field_to_schema(%{type: "unknown"}, _nsid) do 288 + :any 289 + end 290 + 291 + defp field_to_schema(_field_def, _nsid), do: nil 292 + 293 + defp maybe_default(schema, field) do 294 + if field[:default] != nil, 295 + do: {schema, {:default, field.default}}, 296 + else: schema 297 + end 298 + 299 + defp const_or_enum(field), do: const(field) || enum(field) 300 + 301 + defp const(%{const: value}), do: {:literal, value} 302 + defp const(_), do: nil 303 + 304 + defp enum(%{enum: values}), do: {:enum, values} 305 + defp enum(_), do: nil 306 + 307 + defp atomise(x) when is_atom(x), do: x 308 + defp atomise(x) when is_binary(x), do: String.to_atom(x) 309 + end
+265
lib/atex/lexicon/schema.ex
···
··· 1 + defmodule Atex.Lexicon.Schema do 2 + import Peri 3 + 4 + defschema :lexicon, %{ 5 + lexicon: {:required, {:literal, 1}}, 6 + id: 7 + {:required, 8 + {:string, 9 + {:regex, 10 + ~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])?)$/}}}, 11 + revision: {:integer, {:gte, 0}}, 12 + description: :string, 13 + defs: { 14 + :required, 15 + {:schema, 16 + %{ 17 + main: 18 + {:oneof, 19 + [ 20 + get_schema(:record), 21 + get_schema(:query), 22 + get_schema(:procedure), 23 + get_schema(:subscription), 24 + get_schema(:user_types) 25 + ]} 26 + }, {:additional_keys, get_schema(:user_types)}} 27 + } 28 + } 29 + 30 + defschema :record, %{ 31 + type: {:required, {:literal, "record"}}, 32 + description: :string, 33 + # TODO: constraint 34 + key: {:required, :string}, 35 + record: {:required, get_schema(:object)} 36 + } 37 + 38 + defschema :query, %{ 39 + type: {:required, {:literal, "query"}}, 40 + description: :string, 41 + parameters: get_schema(:parameters), 42 + output: get_schema(:body), 43 + errors: {:list, get_schema(:error)} 44 + } 45 + 46 + defschema :procedure, %{ 47 + type: {:required, {:literal, "procedure"}}, 48 + description: :string, 49 + parameters: get_schema(:parameters), 50 + input: get_schema(:body), 51 + output: get_schema(:body), 52 + errors: {:list, get_schema(:error)} 53 + } 54 + 55 + defschema :subscription, %{ 56 + type: {:required, {:literal, "subscription"}}, 57 + description: :string, 58 + parameters: get_schema(:parameters), 59 + message: %{ 60 + description: :string, 61 + schema: {:oneof, [get_schema(:object), get_schema(:ref_variant)]} 62 + }, 63 + errors: {:list, get_schema(:error)} 64 + } 65 + 66 + defschema :parameters, %{ 67 + type: {:required, {:literal, "params"}}, 68 + description: :string, 69 + required: {{:list, :string}, {:default, []}}, 70 + properties: 71 + {:required, {:map, {:either, {get_schema(:primitive), get_schema(:primitive_array)}}}} 72 + } 73 + 74 + defschema :body, %{ 75 + description: :string, 76 + encoding: {:required, :string}, 77 + schema: {:oneof, [get_schema(:object), get_schema(:ref_variant)]} 78 + } 79 + 80 + defschema :error, %{ 81 + name: {:required, :string}, 82 + description: :string 83 + } 84 + 85 + defschema :user_types, 86 + {:oneof, 87 + [ 88 + get_schema(:blob), 89 + get_schema(:array), 90 + get_schema(:token), 91 + get_schema(:object), 92 + get_schema(:boolean), 93 + get_schema(:integer), 94 + get_schema(:string), 95 + get_schema(:bytes), 96 + get_schema(:cid_link), 97 + get_schema(:unknown) 98 + ]} 99 + 100 + # General types 101 + 102 + @ref_value {:string, 103 + { 104 + :regex, 105 + # TODO: minlength 1 106 + ~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])?)?$/ 107 + }} 108 + 109 + @positive_int {:integer, {:gte, 0}} 110 + @nonzero_positive_int {:integer, {:gt, 0}} 111 + 112 + defschema :ref_variant, {:oneof, [get_schema(:ref), get_schema(:ref_union)]} 113 + 114 + defschema :ref, %{ 115 + type: {:required, {:literal, "ref"}}, 116 + description: :string, 117 + ref: {:required, @ref_value} 118 + } 119 + 120 + defschema :ref_union, %{ 121 + type: {:required, {:literal, "union"}}, 122 + description: :string, 123 + refs: {:required, {:list, @ref_value}} 124 + } 125 + 126 + defschema :array, %{ 127 + type: {:required, {:literal, "array"}}, 128 + description: :string, 129 + items: 130 + {:required, 131 + {:oneof, 132 + [get_schema(:primitive), get_schema(:ipld), get_schema(:blob), get_schema(:ref_variant)]}}, 133 + maxLength: @positive_int, 134 + minLength: @positive_int 135 + } 136 + 137 + defschema :primitive_array, %{ 138 + type: {:required, {:literal, "array"}}, 139 + description: :string, 140 + items: {:required, get_schema(:primitive)}, 141 + maxLength: @positive_int, 142 + minLength: @positive_int 143 + } 144 + 145 + defschema :object, %{ 146 + type: {:required, {:literal, "object"}}, 147 + description: :string, 148 + required: {{:list, :string}, {:default, []}}, 149 + nullable: {{:list, :string}, {:default, []}}, 150 + properties: 151 + {:required, 152 + {:map, 153 + {:oneof, 154 + [ 155 + get_schema(:ref_variant), 156 + get_schema(:ipld), 157 + get_schema(:array), 158 + get_schema(:blob), 159 + get_schema(:primitive) 160 + ]}}} 161 + } 162 + 163 + defschema :primitive, 164 + {:oneof, 165 + [ 166 + get_schema(:boolean), 167 + get_schema(:integer), 168 + get_schema(:string), 169 + get_schema(:unknown) 170 + ]} 171 + 172 + defschema :ipld, {:oneof, [get_schema(:bytes), get_schema(:cid_link)]} 173 + 174 + defschema :blob, %{ 175 + type: {:required, {:literal, "blob"}}, 176 + description: :string, 177 + accept: {:list, :string}, 178 + maxSize: @positive_int 179 + } 180 + 181 + defschema :boolean, %{ 182 + type: {:required, {:literal, "boolean"}}, 183 + description: :string, 184 + default: :boolean, 185 + const: :boolean 186 + } 187 + 188 + defschema :bytes, %{ 189 + type: {:required, {:literal, "bytes"}}, 190 + description: :string, 191 + maxLength: @positive_int, 192 + minLength: @positive_int 193 + } 194 + 195 + defschema :cid_link, %{ 196 + type: {:required, {:literal, "cid-link"}}, 197 + description: :string 198 + } 199 + 200 + @string_type {:required, {:literal, "string"}} 201 + 202 + defschema :string, 203 + {:either, 204 + { 205 + # Formatted 206 + %{ 207 + type: @string_type, 208 + format: 209 + {:required, 210 + {:enum, 211 + [ 212 + "at-identifier", 213 + "at-uri", 214 + "cid", 215 + "datetime", 216 + "did", 217 + "handle", 218 + "language", 219 + "nsid", 220 + "record-key", 221 + "tid", 222 + "uri" 223 + ]}}, 224 + description: :string, 225 + default: :string, 226 + const: :string, 227 + enum: {:list, :string}, 228 + knownValues: {:list, :string} 229 + }, 230 + # Unformatted 231 + %{ 232 + type: @string_type, 233 + description: :string, 234 + default: :string, 235 + const: :string, 236 + enum: {:list, :string}, 237 + knownValues: {:list, :string}, 238 + format: {:literal, nil}, 239 + maxLength: @nonzero_positive_int, 240 + minLength: @nonzero_positive_int, 241 + maxGraphemes: @nonzero_positive_int, 242 + minGraphemes: @nonzero_positive_int 243 + } 244 + }} 245 + 246 + defschema :integer, %{ 247 + type: {:required, {:literal, "integer"}}, 248 + description: :string, 249 + default: @positive_int, 250 + const: @positive_int, 251 + enum: {:list, @positive_int}, 252 + maximum: @positive_int, 253 + minimum: @positive_int 254 + } 255 + 256 + defschema :token, %{ 257 + type: {:required, {:literal, "token"}}, 258 + description: :string 259 + } 260 + 261 + defschema :unknown, %{ 262 + type: {:required, {:literal, "unknown"}}, 263 + description: :string 264 + } 265 + end
+26 -5
lib/atex/lexicon/validators.ex
··· 1 defmodule Atex.Lexicon.Validators do 2 alias Atex.Lexicon.Validators 3 4 - @type blob_option() :: {:accept, list(String.t())} | {:max_size, integer()} 5 6 @type blob_t() :: 7 %{ 8 "$type": String.t(), 9 - req: %{"$link": String.t()}, 10 mimeType: String.t(), 11 size: integer() 12 } 13 - | %{} 14 15 @spec string(list(Validators.String.option())) :: Peri.custom_def() 16 def string(options \\ []), do: {:custom, {Validators.String, :validate, [options]}} ··· 20 21 @spec array(Peri.schema_def(), list(Validators.Array.option())) :: Peri.custom_def() 22 def array(inner_type, options \\ []) do 23 - {:ok, ^inner_type} = Peri.validate_schema(inner_type) 24 {:custom, {Validators.Array, :validate, [inner_type, options]}} 25 end 26 ··· 49 }, 50 # Old deprecated blobs 51 %{ 52 - cid: {:reqiured, :string}, 53 mimeType: mime_type 54 } 55 } 56 } 57 end 58
··· 1 defmodule Atex.Lexicon.Validators do 2 alias Atex.Lexicon.Validators 3 4 + @type blob_option() :: {:accept, list(String.t())} | {:max_size, pos_integer()} 5 6 @type blob_t() :: 7 %{ 8 "$type": String.t(), 9 + ref: %{"$link": String.t()}, 10 mimeType: String.t(), 11 size: integer() 12 } 13 + | %{ 14 + cid: String.t(), 15 + mimeType: String.t() 16 + } 17 18 @spec string(list(Validators.String.option())) :: Peri.custom_def() 19 def string(options \\ []), do: {:custom, {Validators.String, :validate, [options]}} ··· 23 24 @spec array(Peri.schema_def(), list(Validators.Array.option())) :: Peri.custom_def() 25 def array(inner_type, options \\ []) do 26 {:custom, {Validators.Array, :validate, [inner_type, options]}} 27 end 28 ··· 51 }, 52 # Old deprecated blobs 53 %{ 54 + cid: {:required, :string}, 55 mimeType: mime_type 56 } 57 } 58 + } 59 + end 60 + 61 + @spec bytes(list(Validators.Bytes.option())) :: Peri.schema() 62 + def bytes(options \\ []) do 63 + options = Keyword.validate!(options, min_length: nil, max_length: nil) 64 + 65 + %{ 66 + "$bytes": 67 + {:required, 68 + {{:custom, {Validators.Bytes, :validate, [options]}}, {:transform, &Base.decode64!/1}}} 69 + } 70 + end 71 + 72 + # TODO: see what atcute validators expect 73 + # TODO: cid validation? 74 + def cid_link() do 75 + %{ 76 + "$link": {:required, :string} 77 } 78 end 79
+32
lib/atex/lexicon/validators/bytes.ex
···
··· 1 + defmodule Atex.Lexicon.Validators.Bytes do 2 + @type option() :: {:min_length, pos_integer()} | {:max_length, pos_integer()} 3 + 4 + @option_keys [:min_length, :max_length] 5 + 6 + @spec validate(term(), list(option())) :: Peri.validation_result() 7 + def validate(value, options) when is_binary(value) do 8 + case Base.decode64(value, padding: false) do 9 + {:ok, bytes} -> 10 + options 11 + |> Keyword.validate!(min_length: nil, max_length: nil) 12 + |> Stream.map(&validate_option(bytes, &1)) 13 + |> Enum.find(:ok, fn x -> x !== :ok end) 14 + 15 + :error -> 16 + {:error, "expected valid base64 encoded bytes", []} 17 + end 18 + end 19 + 20 + def validate(value, _options), 21 + do: 22 + {:error, "expected valid base64 encoded bytes, received #{value}", 23 + [expected: :bytes, actual: value]} 24 + 25 + defp validate_option(_value, {option, nil}) when option in @option_keys, do: :ok 26 + 27 + defp validate_option(value, {:min_length, expected}) when byte_size(value) < expected, 28 + do: {:error, "should have a minimum byte length of #{expected}", [length: expected]} 29 + 30 + defp validate_option(value, {:max_length, expected}) when byte_size(value) <= expected, 31 + do: :ok 32 + end
+2 -17
lib/atex/lexicon/validators/integer.ex
··· 4 @type option() :: 5 {:minimum, integer()} 6 | {:maximum, integer()} 7 - | {:enum, list(integer())} 8 - | {:const, integer()} 9 10 - @option_keys [:minimum, :maximum, :enum, :const] 11 12 @spec validate(term(), list(option())) :: Peri.validation_result() 13 def validate(value, options) when is_integer(value) do 14 options 15 |> Keyword.validate!( 16 minimum: nil, 17 - maximum: nil, 18 - enum: nil, 19 - const: nil 20 ) 21 |> Stream.map(&validate_option(value, &1)) 22 |> Enum.find(:ok, fn x -> x != :ok end) ··· 41 42 defp validate_option(value, {:maximum, expected}) when value > expected, 43 do: {:error, "", [value: expected]} 44 - 45 - defp validate_option(value, {:enum, values}), 46 - do: 47 - Validators.boolean_validate(value in values, "should be one of the expected values", 48 - enum: values 49 - ) 50 - 51 - defp validate_option(value, {:const, expected}) when value == expected, do: :ok 52 - 53 - defp validate_option(value, {:const, expected}), 54 - do: {:error, "should match constant value", [actual: value, expected: expected]} 55 end
··· 4 @type option() :: 5 {:minimum, integer()} 6 | {:maximum, integer()} 7 8 + @option_keys [:minimum, :maximum] 9 10 @spec validate(term(), list(option())) :: Peri.validation_result() 11 def validate(value, options) when is_integer(value) do 12 options 13 |> Keyword.validate!( 14 minimum: nil, 15 + maximum: nil 16 ) 17 |> Stream.map(&validate_option(value, &1)) 18 |> Enum.find(:ok, fn x -> x != :ok end) ··· 37 38 defp validate_option(value, {:maximum, expected}) when value > expected, 39 do: {:error, "", [value: expected]} 40 end
+2 -19
lib/atex/lexicon/validators/string.ex
··· 20 | {:max_length, non_neg_integer()} 21 | {:min_graphemes, non_neg_integer()} 22 | {:max_graphemes, non_neg_integer()} 23 - | {:enum, list(String.t())} 24 - | {:const, String.t()} 25 26 @option_keys [ 27 :format, 28 :min_length, 29 :max_length, 30 :min_graphemes, 31 - :max_graphemes, 32 - :enum, 33 - :const 34 ] 35 36 @record_key_re ~r"^[a-zA-Z0-9.-_:~]$" ··· 62 min_length: nil, 63 max_length: nil, 64 min_graphemes: nil, 65 - max_graphemes: nil, 66 - enum: nil, 67 - const: nil 68 ) 69 # Stream so we early exit at the first error. 70 |> Stream.map(&validate_option(value, &1)) ··· 168 "should have a maximum length of #{expected}", 169 length: expected 170 ) 171 - 172 - defp validate_option(value, {:enum, values}), 173 - do: 174 - Validators.boolean_validate(value in values, "should be one of the expected values", 175 - enum: values 176 - ) 177 - 178 - defp validate_option(value, {:const, expected}) when value == expected, do: :ok 179 - 180 - defp validate_option(value, {:const, expected}), 181 - do: {:error, "should match constant value", [actual: value, expected: expected]} 182 end
··· 20 | {:max_length, non_neg_integer()} 21 | {:min_graphemes, non_neg_integer()} 22 | {:max_graphemes, non_neg_integer()} 23 24 @option_keys [ 25 :format, 26 :min_length, 27 :max_length, 28 :min_graphemes, 29 + :max_graphemes 30 ] 31 32 @record_key_re ~r"^[a-zA-Z0-9.-_:~]$" ··· 58 min_length: nil, 59 max_length: nil, 60 min_graphemes: nil, 61 + max_graphemes: nil 62 ) 63 # Stream so we early exit at the first error. 64 |> Stream.map(&validate_option(value, &1)) ··· 162 "should have a maximum length of #{expected}", 163 length: expected 164 ) 165 end
+29
lib/atex/nsid.ex
··· 9 10 # TODO: methods for fetching the authority and name from a nsid. 11 # maybe stuff for fetching the repo that belongs to an authority 12 end
··· 9 10 # TODO: methods for fetching the authority and name from a nsid. 11 # maybe stuff for fetching the repo that belongs to an authority 12 + 13 + @spec to_atom(String.t()) :: atom() 14 + def to_atom(nsid) do 15 + nsid 16 + |> String.split(".") 17 + |> Enum.map(&String.capitalize/1) 18 + |> then(&["Elixir" | &1]) 19 + |> Enum.join(".") 20 + |> String.to_atom() 21 + end 22 + 23 + @spec to_atom_with_fragment(String.t()) :: {atom(), atom()} 24 + def to_atom_with_fragment(nsid) do 25 + if !String.contains?(nsid, "#") do 26 + {to_atom(nsid), :main} 27 + else 28 + [nsid, fragment] = String.split(nsid, "#") 29 + {to_atom(nsid), String.to_atom(fragment)} 30 + end 31 + end 32 + 33 + @spec expand_possible_fragment_shorthand(String.t(), String.t()) :: String.t() 34 + def expand_possible_fragment_shorthand(main_nsid, possible_fragment) do 35 + if String.starts_with?(possible_fragment, "#") do 36 + main_nsid <> possible_fragment 37 + else 38 + possible_fragment 39 + end 40 + end 41 end
+23
lib/atex/peri.ex
··· 14 end 15 16 defp validate_uri(uri), do: {:error, "must be a valid URI", [uri: uri]} 17 end
··· 14 end 15 16 defp validate_uri(uri), do: {:error, "must be a valid URI", [uri: uri]} 17 + 18 + def validate_map(value, schema, extra_keys_schema) when is_map(value) and is_map(schema) do 19 + extra_keys = 20 + Enum.reduce(Map.keys(schema), MapSet.new(Map.keys(value)), fn key, acc -> 21 + acc |> MapSet.delete(key) |> MapSet.delete(to_string(key)) 22 + end) 23 + 24 + extra_data = 25 + value 26 + |> Enum.filter(fn {key, _} -> MapSet.member?(extra_keys, key) end) 27 + |> Map.new() 28 + 29 + with {:ok, schema_data} <- Peri.validate(schema, value), 30 + {:ok, extra_data} <- Peri.validate(extra_keys_schema, extra_data) do 31 + {:ok, Map.merge(schema_data, extra_data)} 32 + else 33 + {:error, %Peri.Error{} = err} -> {:error, [err]} 34 + e -> e 35 + end 36 + end 37 + 38 + def validate_map(value, _schema, _extra_keys_schema), 39 + do: {:error, "must be a map", [value: value]} 40 end
+115
lib/atproto/sh/comet/v0/actor/profile.ex
···
··· 1 + defmodule Sh.Comet.V0.Actor.Profile do 2 + use Atex.Lexicon 3 + 4 + deflexicon(%{ 5 + "defs" => %{ 6 + "main" => %{ 7 + "description" => "A user's Comet profile.", 8 + "key" => "literal:self", 9 + "record" => %{ 10 + "properties" => %{ 11 + "avatar" => %{ 12 + "accept" => ["image/png", "image/jpeg"], 13 + "description" => 14 + "Small image to be displayed next to posts from account. AKA, 'profile picture'", 15 + "maxSize" => 1_000_000, 16 + "type" => "blob" 17 + }, 18 + "banner" => %{ 19 + "accept" => ["image/png", "image/jpeg"], 20 + "description" => "Larger horizontal image to display behind profile view.", 21 + "maxSize" => 1_000_000, 22 + "type" => "blob" 23 + }, 24 + "createdAt" => %{"format" => "datetime", "type" => "string"}, 25 + "description" => %{ 26 + "description" => "Free-form profile description text.", 27 + "maxGraphemes" => 256, 28 + "maxLength" => 2560, 29 + "type" => "string" 30 + }, 31 + "descriptionFacets" => %{ 32 + "description" => "Annotations of the user's description.", 33 + "ref" => "sh.comet.v0.richtext.facet", 34 + "type" => "ref" 35 + }, 36 + "displayName" => %{ 37 + "maxGraphemes" => 64, 38 + "maxLength" => 640, 39 + "type" => "string" 40 + }, 41 + "featuredItems" => %{ 42 + "description" => "Pinned items to be shown first on the user's profile.", 43 + "items" => %{"format" => "at-uri", "type" => "string"}, 44 + "maxLength" => 5, 45 + "type" => "array" 46 + } 47 + }, 48 + "type" => "object" 49 + }, 50 + "type" => "record" 51 + }, 52 + "view" => %{ 53 + "properties" => %{ 54 + "avatar" => %{"format" => "uri", "type" => "string"}, 55 + "createdAt" => %{"format" => "datetime", "type" => "string"}, 56 + "did" => %{"format" => "did", "type" => "string"}, 57 + "displayName" => %{ 58 + "maxGraphemes" => 64, 59 + "maxLength" => 640, 60 + "type" => "string" 61 + }, 62 + "handle" => %{"format" => "handle", "type" => "string"}, 63 + "indexedAt" => %{"format" => "datetime", "type" => "string"}, 64 + "viewer" => %{"ref" => "#viewerState", "type" => "ref"} 65 + }, 66 + "required" => ["did", "handle"], 67 + "type" => "object" 68 + }, 69 + "viewFull" => %{ 70 + "properties" => %{ 71 + "avatar" => %{"format" => "uri", "type" => "string"}, 72 + "banner" => %{"format" => "uri", "type" => "string"}, 73 + "createdAt" => %{"format" => "datetime", "type" => "string"}, 74 + "description" => %{ 75 + "maxGraphemes" => 256, 76 + "maxLength" => 2560, 77 + "type" => "string" 78 + }, 79 + "descriptionFacets" => %{ 80 + "ref" => "sh.comet.v0.richtext.facet", 81 + "type" => "ref" 82 + }, 83 + "did" => %{"format" => "did", "type" => "string"}, 84 + "displayName" => %{ 85 + "maxGraphemes" => 64, 86 + "maxLength" => 640, 87 + "type" => "string" 88 + }, 89 + "featuredItems" => %{ 90 + "items" => %{"format" => "at-uri", "type" => "string"}, 91 + "maxLength" => 5, 92 + "type" => "array" 93 + }, 94 + "followersCount" => %{"type" => "integer"}, 95 + "followsCount" => %{"type" => "integer"}, 96 + "handle" => %{"format" => "handle", "type" => "string"}, 97 + "indexedAt" => %{"format" => "datetime", "type" => "string"}, 98 + "playlistsCount" => %{"type" => "integer"}, 99 + "tracksCount" => %{"type" => "integer"}, 100 + "viewer" => %{"ref" => "#viewerState", "type" => "ref"} 101 + }, 102 + "required" => ["did", "handle"], 103 + "type" => "object" 104 + }, 105 + "viewerState" => %{ 106 + "description" => 107 + "Metadata about the requesting account's relationship with the user. TODO: determine if we create our own graph or inherit bsky's.", 108 + "properties" => %{}, 109 + "type" => "object" 110 + } 111 + }, 112 + "id" => "sh.comet.v0.actor.profile", 113 + "lexicon" => 1 114 + }) 115 + end
+44
lib/atproto/sh/comet/v0/feed/defs.ex
···
··· 1 + defmodule Sh.Comet.V0.Feed.Defs do 2 + use Atex.Lexicon 3 + 4 + deflexicon(%{ 5 + "defs" => %{ 6 + "buyLink" => %{ 7 + "description" => "Indicate the link leads to a purchase page for the track.", 8 + "type" => "token" 9 + }, 10 + "downloadLink" => %{ 11 + "description" => "Indicate the link leads to a free download for the track.", 12 + "type" => "token" 13 + }, 14 + "link" => %{ 15 + "description" => 16 + "Link for the track. Usually to acquire it in some way, e.g. via free download or purchase. | TODO: multiple links?", 17 + "properties" => %{ 18 + "type" => %{ 19 + "knownValues" => [ 20 + "sh.comet.v0.feed.defs#downloadLink", 21 + "sh.comet.v0.feed.defs#buyLink" 22 + ], 23 + "type" => "string" 24 + }, 25 + "value" => %{"format" => "uri", "type" => "string"} 26 + }, 27 + "required" => ["type", "value"], 28 + "type" => "object" 29 + }, 30 + "viewerState" => %{ 31 + "description" => 32 + "Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests.", 33 + "properties" => %{ 34 + "featured" => %{"type" => "boolean"}, 35 + "like" => %{"format" => "at-uri", "type" => "string"}, 36 + "repost" => %{"format" => "at-uri", "type" => "string"} 37 + }, 38 + "type" => "object" 39 + } 40 + }, 41 + "id" => "sh.comet.v0.feed.defs", 42 + "lexicon" => 1 43 + }) 44 + end
+45
lib/atproto/sh/comet/v0/feed/getActorTracks.ex
···
··· 1 + defmodule Sh.Comet.V0.Feed.GetActorTracks do 2 + use Atex.Lexicon 3 + 4 + deflexicon(%{ 5 + "defs" => %{ 6 + "main" => %{ 7 + "description" => "Get a list of an actor's tracks.", 8 + "output" => %{ 9 + "encoding" => "application/json", 10 + "schema" => %{ 11 + "properties" => %{ 12 + "cursor" => %{"type" => "string"}, 13 + "tracks" => %{ 14 + "items" => %{ 15 + "ref" => "sh.comet.v0.feed.track#view", 16 + "type" => "ref" 17 + }, 18 + "type" => "array" 19 + } 20 + }, 21 + "required" => ["tracks"], 22 + "type" => "object" 23 + } 24 + }, 25 + "parameters" => %{ 26 + "properties" => %{ 27 + "actor" => %{"format" => "at-identifier", "type" => "string"}, 28 + "cursor" => %{"type" => "string"}, 29 + "limit" => %{ 30 + "default" => 50, 31 + "maximum" => 100, 32 + "minimum" => 1, 33 + "type" => "integer" 34 + } 35 + }, 36 + "required" => ["actor"], 37 + "type" => "params" 38 + }, 39 + "type" => "query" 40 + } 41 + }, 42 + "id" => "sh.comet.v0.feed.getActorTracks", 43 + "lexicon" => 1 44 + }) 45 + end
+189
lib/atproto/sh/comet/v0/feed/track.ex
···
··· 1 + defmodule Sh.Comet.V0.Feed.Track do 2 + @moduledoc """ 3 + The following `deflexicon` call should result in something similar to the following output: 4 + 5 + import Peri 6 + import Atex.Lexicon.Validators 7 + 8 + @type main() :: %{} 9 + 10 + """ 11 + use Atex.Lexicon 12 + # import Atex.Lexicon 13 + # import Atex.Lexicon.Validators 14 + # import Peri 15 + 16 + # TODO: need an example with `nullable` fields to demonstrate how those are handled (and also the weird extra types in lexicon defs like union) 17 + 18 + @type main() :: %{ 19 + required(:audio) => Atex.Lexicon.Validators.blob_t(), 20 + required(:title) => String.t(), 21 + required(:createdAt) => String.t(), 22 + # TODO: check if peri replaces with `nil` or omits them completely. 23 + optional(:description) => String.t(), 24 + optional(:descriptionFacets) => Sh.Comet.V0.Richtext.Facet.main(), 25 + optional(:explicit) => boolean(), 26 + optional(:image) => Atex.Lexicon.Validators.blob_t(), 27 + optional(:link) => Sh.Comet.V0.Feed.Defs.link(), 28 + optional(:releasedAt) => String.t(), 29 + optional(:tags) => list(String.t()) 30 + } 31 + 32 + @type view() :: %{ 33 + required(:uri) => String.t(), 34 + required(:cid) => String.t(), 35 + required(:author) => Sh.Comet.V0.Actor.Profile.viewFull(), 36 + required(:audio) => String.t(), 37 + required(:record) => main(), 38 + required(:indexedAt) => String.t(), 39 + optional(:image) => String.t(), 40 + optional(:commentCount) => integer(), 41 + optional(:likeCount) => integer(), 42 + optional(:playCount) => integer(), 43 + optional(:repostCount) => integer(), 44 + optional(:viewer) => Sh.Comet.V0.Feed.Defs.viewerState() 45 + } 46 + 47 + # Should probably be a separate validator for all rkey formats. 48 + # defschema :main_rkey, string(format: :tid) 49 + 50 + # defschema :main, %{ 51 + # audio: {:required, blob(accept: ["audio/ogg"], max_size: 100_000_000)}, 52 + # title: {:required, string(min_length: 1, max_length: 2560, max_graphemes: 256)}, 53 + # createdAt: {:required, string(format: :datetime)}, 54 + # description: string(max_length: 20000, max_graphemes: 2000), 55 + # # This is `ref` 56 + # descriptionFacets: Sh.Comet.V0.Richtext.Facet.get_schema(:main), 57 + # explicit: :boolean, 58 + # image: blob(accept: ["image/png", "image/jpeg"], max_size: 1_000_000), 59 + # link: Sh.Comet.V0.Feed.Defs.get_schema(:link), 60 + # releasedAt: string(format: :datetime), 61 + # tags: array(string(max_graphemes: 64, max_length: 640), max_length: 8) 62 + # } 63 + 64 + # defschema :view, %{ 65 + # uri: {:required, string(format: :at_uri)}, 66 + # cid: {:required, string(format: :cid)}, 67 + # author: {:required, Sh.Comet.V0.Actor.Profile.get_schema(:viewFull)}, 68 + # audio: {:required, string(format: :uri)}, 69 + # record: {:required, get_schema(:main)}, 70 + # indexedAt: {:required, string(format: :datetime)}, 71 + # image: string(format: :uri), 72 + # commentCount: :integer, 73 + # likeCount: :integer, 74 + # playCount: :integer, 75 + # repostCount: :integer, 76 + # viewer: Sh.Comet.V0.Feed.Defs.get_schema(:viewerState) 77 + # } 78 + 79 + deflexicon(%{ 80 + "defs" => %{ 81 + "main" => %{ 82 + "description" => 83 + "A Comet audio track. TODO: should probably have some sort of pre-calculated waveform, or have a query to get one from a blob?", 84 + "key" => "tid", 85 + "record" => %{ 86 + "properties" => %{ 87 + "audio" => %{ 88 + "accept" => ["audio/ogg"], 89 + "description" => 90 + "Audio of the track, ideally encoded as 96k Opus. Limited to 100mb.", 91 + "maxSize" => 100_000_000, 92 + "type" => "blob" 93 + }, 94 + "createdAt" => %{ 95 + "description" => "Timestamp for when the track entry was originally created.", 96 + "format" => "datetime", 97 + "type" => "string" 98 + }, 99 + "description" => %{ 100 + "description" => "Description of the track.", 101 + "maxGraphemes" => 2000, 102 + "maxLength" => 20000, 103 + "type" => "string" 104 + }, 105 + "descriptionFacets" => %{ 106 + "description" => "Annotations of the track's description.", 107 + "ref" => "sh.comet.v0.richtext.facet", 108 + "type" => "ref" 109 + }, 110 + "explicit" => %{ 111 + "description" => 112 + "Whether the track contains explicit content that may objectionable to some people, usually swearing or adult themes.", 113 + "type" => "boolean" 114 + }, 115 + "image" => %{ 116 + "accept" => ["image/png", "image/jpeg"], 117 + "description" => "Image to be displayed representing the track.", 118 + "maxSize" => 1_000_000, 119 + "type" => "blob" 120 + }, 121 + "link" => %{"ref" => "sh.comet.v0.feed.defs#link", "type" => "ref"}, 122 + "releasedAt" => %{ 123 + "description" => 124 + "Timestamp for when the track was released. If in the future, may be used to implement pre-savable tracks.", 125 + "format" => "datetime", 126 + "type" => "string" 127 + }, 128 + "tags" => %{ 129 + "description" => "Hashtags for the track, usually for genres.", 130 + "items" => %{ 131 + "maxGraphemes" => 64, 132 + "maxLength" => 640, 133 + "type" => "string" 134 + }, 135 + "maxLength" => 8, 136 + "type" => "array" 137 + }, 138 + "title" => %{ 139 + "description" => 140 + "Title of the track. Usually shouldn't include the creator's name.", 141 + "maxGraphemes" => 256, 142 + "maxLength" => 2560, 143 + "minLength" => 1, 144 + "type" => "string" 145 + } 146 + }, 147 + "required" => ["audio", "title", "createdAt"], 148 + "type" => "object" 149 + }, 150 + "type" => "record" 151 + }, 152 + "view" => %{ 153 + "properties" => %{ 154 + "audio" => %{ 155 + "description" => 156 + "URL pointing to where the audio data for the track can be fetched. May be re-encoded from the original blob.", 157 + "format" => "uri", 158 + "type" => "string" 159 + }, 160 + "author" => %{ 161 + "ref" => "sh.comet.v0.actor.profile#viewFull", 162 + "type" => "ref" 163 + }, 164 + "cid" => %{"format" => "cid", "type" => "string"}, 165 + "commentCount" => %{"type" => "integer"}, 166 + "image" => %{ 167 + "description" => "URL pointing to where the image for the track can be fetched.", 168 + "format" => "uri", 169 + "type" => "string" 170 + }, 171 + "indexedAt" => %{"format" => "datetime", "type" => "string"}, 172 + "likeCount" => %{"type" => "integer"}, 173 + "playCount" => %{"type" => "integer"}, 174 + "record" => %{"ref" => "#main", "type" => "ref"}, 175 + "repostCount" => %{"type" => "integer"}, 176 + "uri" => %{"format" => "at-uri", "type" => "string"}, 177 + "viewer" => %{ 178 + "ref" => "sh.comet.v0.feed.defs#viewerState", 179 + "type" => "ref" 180 + } 181 + }, 182 + "required" => ["uri", "cid", "author", "audio", "record", "indexedAt"], 183 + "type" => "object" 184 + } 185 + }, 186 + "id" => "sh.comet.v0.feed.track", 187 + "lexicon" => 1 188 + }) 189 + end
+70
lib/atproto/sh/comet/v0/richtext/facet.ex
···
··· 1 + defmodule Sh.Comet.V0.Richtext.Facet do 2 + use Atex.Lexicon 3 + 4 + deflexicon(%{ 5 + "defs" => %{ 6 + "byteSlice" => %{ 7 + "description" => 8 + "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.", 9 + "properties" => %{ 10 + "byteEnd" => %{"minimum" => 0, "type" => "integer"}, 11 + "byteStart" => %{"minimum" => 0, "type" => "integer"} 12 + }, 13 + "required" => ["byteStart", "byteEnd"], 14 + "type" => "object" 15 + }, 16 + "link" => %{ 17 + "description" => 18 + "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.", 19 + "properties" => %{"uri" => %{"format" => "uri", "type" => "string"}}, 20 + "required" => ["uri"], 21 + "type" => "object" 22 + }, 23 + "main" => %{ 24 + "description" => "Annotation of a sub-string within rich text.", 25 + "properties" => %{ 26 + "features" => %{ 27 + "items" => %{ 28 + "refs" => ["#mention", "#link", "#tag"], 29 + "type" => "union" 30 + }, 31 + "type" => "array" 32 + }, 33 + "index" => %{"ref" => "#byteSlice", "type" => "ref"} 34 + }, 35 + "required" => ["index", "features"], 36 + "type" => "object" 37 + }, 38 + "mention" => %{ 39 + "description" => 40 + "Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID.", 41 + "properties" => %{"did" => %{"format" => "did", "type" => "string"}}, 42 + "required" => ["did"], 43 + "type" => "object" 44 + }, 45 + "tag" => %{ 46 + "description" => 47 + "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').", 48 + "properties" => %{ 49 + "tag" => %{"maxGraphemes" => 64, "maxLength" => 640, "type" => "string"} 50 + }, 51 + "required" => ["tag"], 52 + "type" => "object" 53 + }, 54 + "timestamp" => %{ 55 + "description" => 56 + "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.", 57 + "properties" => %{ 58 + "timestamp" => %{ 59 + "description" => "Reference time, in seconds.", 60 + "minimum" => 0, 61 + "type" => "integer" 62 + } 63 + }, 64 + "type" => "object" 65 + } 66 + }, 67 + "id" => "sh.comet.v0.richtext.facet", 68 + "lexicon" => 1 69 + }) 70 + end
+1 -1
mix.exs
··· 27 28 defp deps do 29 [ 30 - {:peri, "~> 0.5"}, 31 {:multiformats_ex, "~> 0.2"}, 32 {:recase, "~> 0.5"}, 33 {:req, "~> 0.5"},
··· 27 28 defp deps do 29 [ 30 + {:peri, "~> 0.6"}, 31 {:multiformats_ex, "~> 0.2"}, 32 {:recase, "~> 0.5"}, 33 {:req, "~> 0.5"},
+1 -1
mix.lock
··· 19 "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 20 "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 21 "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 22 - "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"}, 23 "recase": {:hex, :recase, "0.8.1", "ab98cd35857a86fa5ca99036f575241d71d77d9c2ab0c39aacf1c9b61f6f7d1d", [:mix], [], "hexpm", "9fd8d63e7e43bd9ea385b12364e305778b2bbd92537e95c4b2e26fc507d5e4c2"}, 24 "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"}, 25 "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
··· 19 "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 20 "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 21 "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 22 + "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"}, 23 "recase": {:hex, :recase, "0.8.1", "ab98cd35857a86fa5ca99036f575241d71d77d9c2ab0c39aacf1c9b61f6f7d1d", [:mix], [], "hexpm", "9fd8d63e7e43bd9ea385b12364e305778b2bbd92537e95c4b2e26fc507d5e4c2"}, 24 "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"}, 25 "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},