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

feat(deflexicon): generate typespecs

ovyerus.com ee40feb3 9e142f68

verified
+146 -112
+141 -36
lib/atex/lexicon.ex
··· 24 24 |> then(&Recase.Enumerable.atomize_keys/1) 25 25 |> then(&Atex.Lexicon.Schema.lexicon!/1) 26 26 27 - # TODO: support returning typedefs 28 27 defs = 29 28 lexicon.defs 30 29 |> Enum.flat_map(fn {def_name, def} -> def_to_schema(lexicon.id, def_name, def) end) 31 - |> Enum.map(fn {schema_key, quoted_schema} -> 30 + |> Enum.map(fn {schema_key, quoted_schema, quoted_type} -> 31 + identity_type = 32 + if schema_key === :main do 33 + quote do 34 + @type t() :: unquote(quoted_type) 35 + end 36 + end 37 + 32 38 quote do 39 + @type unquote(schema_key)() :: unquote(quoted_type) 40 + unquote(identity_type) 41 + 33 42 defschema unquote(schema_key), unquote(quoted_schema) 34 43 end 35 44 end) ··· 41 50 end 42 51 end 43 52 44 - # TODO: generate typedefs 45 53 @spec def_to_schema(nsid :: String.t(), def_name :: String.t(), lexicon_def :: map()) :: 46 - list({key :: atom(), quoted :: term()}) 54 + list({key :: atom(), quoted_schema :: term(), quoted_type :: term()}) 47 55 48 56 defp def_to_schema(nsid, def_name, %{type: "record", record: record}) do 49 57 # TODO: record rkey format validator 50 58 def_to_schema(nsid, def_name, record) 51 59 end 52 60 61 + # TODO: add `$type` field. It's just a string though. 53 62 defp def_to_schema( 54 63 nsid, 55 64 def_name, ··· 63 72 64 73 properties 65 74 |> 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}) 75 + {quoted_schema, quoted_type} = field_to_schema(field, nsid) 76 + is_nullable = key in nullable 77 + is_required = key in required 78 + 79 + quoted_schema = 80 + quoted_schema 81 + |> then( 82 + &if is_nullable, do: quote(do: {:either, {{:literal, nil}, unquote(&1)}}), else: &1 83 + ) 84 + |> then(&if is_required, do: quote(do: {:required, unquote(&1)}), else: &1) 85 + |> then(&{key, &1}) 86 + 87 + key_type = if is_required, do: :required, else: :optional 88 + 89 + quoted_type = 90 + quoted_type 91 + |> then( 92 + &if is_nullable do 93 + {:|, [], [&1, nil]} 94 + else 95 + &1 96 + end 97 + ) 98 + |> then(&{{key_type, [], [key]}, &1}) 99 + 100 + {quoted_schema, quoted_type} 101 + end) 102 + |> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} -> 103 + {[quoted_schema | schemas], [quoted_type | types]} 72 104 end) 73 - |> then(&{:%{}, [], &1}) 74 - |> then(&[{atomise(def_name), &1}]) 105 + |> then(fn {quoted_schemas, quoted_types} -> 106 + [{atomise(def_name), {:%{}, [], quoted_schemas}, {:%{}, [], quoted_types}}] 107 + end) 75 108 end 76 109 77 110 # TODO: validating errors? ··· 154 187 155 188 defp def_to_schema(_nsid, def_name, %{type: "token"}) do 156 189 # TODO: make it a validator that expects the nsid + key. 157 - [{atomise(def_name), :string}] 190 + [ 191 + { 192 + atomise(def_name), 193 + :string, 194 + quote do 195 + String.t() 196 + end 197 + } 198 + ] 158 199 end 159 200 160 201 defp def_to_schema(nsid, def_name, %{type: type} = def) ··· 168 209 "cid-link", 169 210 "unknown" 170 211 ] do 171 - [{atomise(def_name), field_to_schema(def, nsid)}] 212 + {quoted_schema, quoted_type} = field_to_schema(def, nsid) 213 + [{atomise(def_name), quoted_schema, quoted_type}] 172 214 end 173 215 174 - @spec field_to_schema(field_def :: %{type: String.t()}, nsid :: String.t()) :: Peri.schema_def() 216 + @spec field_to_schema(field_def :: %{type: String.t()}, nsid :: String.t()) :: 217 + {quoted_schema :: term(), quoted_typespec :: term()} 175 218 defp field_to_schema(%{type: "string"} = field, _nsid) do 176 219 fixed_schema = const_or_enum(field) 177 220 ··· 189 232 |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end) 190 233 |> then(&{:custom, {Validators.String, :validate, [&1]}}) 191 234 |> maybe_default(field) 192 - |> then(&Macro.escape/1) 235 + |> then( 236 + &{Macro.escape(&1), 237 + quote do 238 + String.t() 239 + end} 240 + ) 193 241 end 194 242 end 195 243 196 244 defp field_to_schema(%{type: "boolean"} = field, _nsid) do 197 245 (const(field) || :boolean) 198 246 |> maybe_default(field) 199 - |> then(&Macro.escape/1) 247 + |> then( 248 + &{Macro.escape(&1), 249 + quote do 250 + boolean() 251 + end} 252 + ) 200 253 end 201 254 202 255 defp field_to_schema(%{type: "integer"} = field, _nsid) do ··· 211 264 |> then(&{:custom, {Validators.Integer, [&1]}}) 212 265 |> maybe_default(field) 213 266 end 214 - |> then(&Macro.escape/1) 267 + |> then( 268 + &{ 269 + Macro.escape(&1), 270 + # TODO: turn into range definition based on maximum/minimum 271 + quote do 272 + integer() 273 + end 274 + } 275 + ) 215 276 end 216 277 217 278 defp field_to_schema(%{type: "array", items: items} = field, nsid) do 218 - inner_schema = field_to_schema(items, nsid) 279 + {inner_schema, inner_type} = field_to_schema(items, nsid) 219 280 220 281 field 221 282 |> Map.take([:maxLength, :minLength]) ··· 228 289 {inner_schema, _} = Code.eval_quoted(quoted_inner_schema) 229 290 {:custom, {:{}, c, [Validators.Array, :validate, [inner_schema | args]]}} 230 291 end) 292 + |> then( 293 + &{&1, 294 + quote do 295 + list(unquote(inner_type)) 296 + end} 297 + ) 231 298 end 232 299 233 300 defp field_to_schema(%{type: "blob"} = field, _nsid) do ··· 235 302 |> Map.take([:accept, :maxSize]) 236 303 |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end) 237 304 |> Validators.blob() 238 - |> then(&Macro.escape/1) 305 + |> then( 306 + &{Macro.escape(&1), 307 + quote do 308 + Validators.blob() 309 + end} 310 + ) 239 311 end 240 312 241 313 defp field_to_schema(%{type: "bytes"} = field, _nsid) do ··· 243 315 |> Map.take([:maxLength, :minLength]) 244 316 |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end) 245 317 |> Validators.bytes() 246 - |> then(&Macro.escape/1) 318 + |> then( 319 + &{Macro.escape(&1), 320 + quote do 321 + Validators.bytes() 322 + end} 323 + ) 247 324 end 248 325 249 326 defp field_to_schema(%{type: "cid-link"}, _nsid) do 250 327 Validators.cid_link() 251 - |> then(&Macro.escape/1) 328 + |> then( 329 + &{Macro.escape(&1), 330 + quote do 331 + Validators.cid_link() 332 + end} 333 + ) 252 334 end 253 335 254 336 # TODO: do i need to make sure these two deal with brands? Check objects in atp.tools ··· 258 340 |> Atex.NSID.expand_possible_fragment_shorthand(ref) 259 341 |> Atex.NSID.to_atom_with_fragment() 260 342 261 - quote do 262 - unquote(nsid).get_schema(unquote(fragment)) 263 - end 343 + {quote do 344 + unquote(nsid).get_schema(unquote(fragment)) 345 + end, 346 + quote do 347 + unquote(nsid).unquote(fragment)() 348 + end} 264 349 end 265 350 266 351 defp field_to_schema(%{type: "union", refs: refs}, nsid) do 267 - # refs = 268 352 refs 269 353 |> Enum.map(fn ref -> 270 354 {nsid, fragment} = ··· 272 356 |> Atex.NSID.expand_possible_fragment_shorthand(ref) 273 357 |> Atex.NSID.to_atom_with_fragment() 274 358 275 - quote do 276 - unquote(nsid).get_schema(unquote(fragment)) 277 - end 359 + {quote do 360 + unquote(nsid).get_schema(unquote(fragment)) 361 + end, 362 + quote do 363 + unquote(nsid).unquote(fragment)() 364 + end} 278 365 end) 279 - |> then( 280 - &quote do 281 - {:oneof, unquote(&1)} 282 - end 283 - ) 366 + |> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} -> 367 + {[quoted_schema | schemas], [quoted_type | types]} 368 + end) 369 + |> then(fn {schemaa, types} -> 370 + {quote do 371 + {:oneof, unquote(schemaa)} 372 + end, 373 + quote do 374 + unquote(join_with_pipe(types)) 375 + end} 376 + end) 284 377 end 285 378 286 379 # TODO: apparently should be a data object, not a primitive? 287 380 defp field_to_schema(%{type: "unknown"}, _nsid) do 288 - :any 381 + {:any, 382 + quote do 383 + term() 384 + end} 289 385 end 290 386 291 - defp field_to_schema(_field_def, _nsid), do: nil 387 + defp field_to_schema(_field_def, _nsid), do: {nil, nil} 292 388 293 389 defp maybe_default(schema, field) do 294 390 if field[:default] != nil, ··· 306 402 307 403 defp atomise(x) when is_atom(x), do: x 308 404 defp atomise(x) when is_binary(x), do: String.to_atom(x) 405 + 406 + defp join_with_pipe(list) when is_list(list) do 407 + [piped] = do_join_with_pipe(list) 408 + piped 409 + end 410 + 411 + defp do_join_with_pipe([head]), do: [head] 412 + defp do_join_with_pipe([head | tail]), do: [{:|, [], [head | do_join_with_pipe(tail)]}] 413 + defp do_join_with_pipe([]), do: [] 309 414 end
+5 -1
lib/atex/lexicon/validators.ex
··· 3 3 4 4 @type blob_option() :: {:accept, list(String.t())} | {:max_size, pos_integer()} 5 5 6 - @type blob_t() :: 6 + @type blob() :: 7 7 %{ 8 8 "$type": String.t(), 9 9 ref: %{"$link": String.t()}, ··· 14 14 cid: String.t(), 15 15 mimeType: String.t() 16 16 } 17 + 18 + @type cid_link() :: %{"$link": String.t()} 19 + 20 + @type bytes() :: %{"$bytes": binary()} 17 21 18 22 @spec string(list(Validators.String.option())) :: Peri.custom_def() 19 23 def string(options \\ []), do: {:custom, {Validators.String, :validate, [options]}}
-75
lib/atproto/sh/comet/v0/feed/track.ex
··· 1 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 2 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 3 79 4 deflexicon(%{ 80 5 "defs" => %{