An Elixir toolkit for the AT Protocol. hexdocs.pm/atex
elixir bluesky atproto decentralization
25
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(lexicons): generate structs for records and objects

+158 -52
+151 -50
lib/atex/lexicon.ex
··· 24 24 |> then(&Recase.Enumerable.atomize_keys/1) 25 25 |> then(&Atex.Lexicon.Schema.lexicon!/1) 26 26 27 + lexicon_id = Atex.NSID.to_atom(lexicon.id) 28 + 27 29 defs = 28 30 lexicon.defs 29 31 |> Enum.flat_map(fn {def_name, def} -> def_to_schema(lexicon.id, def_name, def) end) 30 - |> Enum.map(fn {schema_key, quoted_schema, quoted_type} -> 32 + |> Enum.map(fn 33 + {schema_key, quoted_schema, quoted_type} -> {schema_key, quoted_schema, quoted_type, nil} 34 + x -> x 35 + end) 36 + |> Enum.map(fn {schema_key, quoted_schema, quoted_type, quoted_struct} -> 31 37 identity_type = 32 - if schema_key === :main do 38 + if schema_key == :main do 33 39 quote do 34 40 @type t() :: unquote(quoted_type) 41 + end 42 + end 43 + 44 + struct_def = 45 + if schema_key == :main do 46 + quoted_struct 47 + else 48 + nested_module_name = 49 + schema_key 50 + |> Recase.to_pascal() 51 + |> atomise() 52 + 53 + quote do 54 + defmodule unquote({:__aliases__, [alias: false], [nested_module_name]}) do 55 + unquote(quoted_struct) 56 + end 35 57 end 36 58 end 37 59 ··· 40 62 unquote(identity_type) 41 63 42 64 defschema unquote(schema_key), unquote(quoted_schema) 65 + 66 + unquote(struct_def) 43 67 end 44 68 end) 45 69 46 - quote do 47 - def id, do: unquote(Atex.NSID.to_atom(lexicon.id)) 70 + foo = 71 + quote do 72 + def id, do: unquote(lexicon_id) 73 + 74 + unquote_splicing(defs) 75 + end 48 76 49 - unquote_splicing(defs) 77 + if lexicon.id == "app.bsky.feed.post" do 78 + IO.puts("-----") 79 + foo |> Macro.expand(__ENV__) |> Macro.to_string() |> IO.puts() 50 80 end 81 + 82 + foo 51 83 end 52 84 85 + # For records and objects: 86 + # - [x] `main` is in core module, otherwise nested with its name (should probably be handled above instead of in `def_to_schema`, like expanding typespecs) 87 + # - [x] Define all keys in the schema, `@enforce`ing non-nullable/required fields 88 + # - [x] `$type` field with the full NSID 89 + # - [x] Custom JSON encoder function that omits optional fields that are `nil`, due to different semantics 90 + # - [ ] Add `$type` to schema but make it optional - allowing unbranded types through, but mismatching brand will fail. 91 + # - [ ] `t()` type should be the struct in it. (add to non-main structs too?) 92 + 53 93 @spec def_to_schema(nsid :: String.t(), def_name :: String.t(), lexicon_def :: map()) :: 54 - list({key :: atom(), quoted_schema :: term(), quoted_type :: term()}) 94 + list( 95 + { 96 + key :: atom(), 97 + quoted_schema :: term(), 98 + quoted_type :: term() 99 + } 100 + | { 101 + key :: atom(), 102 + quoted_schema :: term(), 103 + quoted_type :: term(), 104 + quoted_struct :: term() 105 + } 106 + ) 55 107 56 108 defp def_to_schema(nsid, def_name, %{type: "record", record: record}) do 57 109 # TODO: record rkey format validator ··· 70 122 required = Map.get(def, :required, []) 71 123 nullable = Map.get(def, :nullable, []) 72 124 73 - properties 74 - |> Enum.map(fn {key, field} -> 75 - {quoted_schema, quoted_type} = field_to_schema(field, nsid) 76 - is_nullable = key in nullable 77 - is_required = key in required 125 + {quoted_schemas, quoted_types} = 126 + properties 127 + |> Enum.map(fn {key, field} -> 128 + {quoted_schema, quoted_type} = field_to_schema(field, nsid) 129 + string_key = to_string(key) 130 + is_nullable = string_key in nullable 131 + is_required = string_key in required 132 + 133 + quoted_schema = 134 + quoted_schema 135 + |> then( 136 + &if is_nullable, do: quote(do: {:either, {{:literal, nil}, unquote(&1)}}), else: &1 137 + ) 138 + |> then(&if is_required, do: quote(do: {:required, unquote(&1)}), else: &1) 139 + |> then(&{key, &1}) 140 + 141 + key_type = if is_required, do: :required, else: :optional 142 + 143 + quoted_type = 144 + quoted_type 145 + |> then( 146 + &if is_nullable do 147 + {:|, [], [&1, nil]} 148 + else 149 + &1 150 + end 151 + ) 152 + |> then(&{{key_type, [], [key]}, &1}) 153 + 154 + {quoted_schema, quoted_type} 155 + end) 156 + |> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} -> 157 + {[quoted_schema | schemas], [quoted_type | types]} 158 + end) 159 + 160 + struct_keys = 161 + Enum.map(properties, fn 162 + {key, %{default: default}} -> {key, default} 163 + {key, _field} -> {key, nil} 164 + end) ++ [{:"$type", if(def_name == :main, do: nsid, else: "#{nsid}##{def_name}")}] 78 165 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}) 166 + enforced_keys = properties |> Map.keys() |> Enum.filter(&(to_string(&1) in required)) 86 167 87 - key_type = if is_required, do: :required, else: :optional 168 + optional_if_nil_keys = 169 + properties 170 + |> Map.keys() 171 + |> Enum.filter(fn key -> 172 + key = to_string(key) 173 + # TODO: what if it is nullable but not required? 174 + key not in required && key not in nullable 175 + end) 88 176 89 - quoted_type = 90 - quoted_type 91 - |> then( 92 - &if is_nullable do 93 - {:|, [], [&1, nil]} 94 - else 95 - &1 177 + quoted_struct = 178 + quote do 179 + @enforce_keys unquote(enforced_keys) 180 + defstruct unquote(struct_keys) 181 + 182 + defimpl JSON.Encoder do 183 + @optional_if_nil_keys unquote(optional_if_nil_keys) 184 + 185 + def encode(value, encoder) do 186 + value 187 + |> Map.from_struct() 188 + |> Enum.reject(fn {k, v} -> k in @optional_if_nil_keys && v == nil end) 189 + |> Enum.into(%{}) 190 + |> Jason.Encoder.encode(encoder) 96 191 end 97 - ) 98 - |> then(&{{key_type, [], [key]}, &1}) 192 + end 99 193 100 - {quoted_schema, quoted_type} 101 - end) 102 - |> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} -> 103 - {[quoted_schema | schemas], [quoted_type | types]} 104 - end) 105 - |> then(fn {quoted_schemas, quoted_types} -> 106 - [{atomise(def_name), {:%{}, [], quoted_schemas}, {:%{}, [], quoted_types}}] 107 - end) 194 + defimpl Jason.Encoder do 195 + @optional_if_nil_keys unquote(optional_if_nil_keys) 196 + 197 + def encode(value, options) do 198 + value 199 + |> Map.from_struct() 200 + |> Enum.reject(fn {k, v} -> k in @optional_if_nil_keys && v == nil end) 201 + |> Enum.into(%{}) 202 + |> Jason.Encode.map(options) 203 + end 204 + end 205 + end 206 + 207 + [{atomise(def_name), {:%{}, [], quoted_schemas}, {:%{}, [], quoted_types}, quoted_struct}] 108 208 end 109 209 110 210 # TODO: validating errors? ··· 231 331 :minGraphemes 232 332 ]) 233 333 |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end) 234 - |> then(&{:custom, {Validators.String, :validate, [&1]}}) 334 + |> Validators.string() 235 335 |> maybe_default(field) 236 336 end 237 337 |> then( ··· 262 362 field 263 363 |> Map.take([:maximum, :minimum]) 264 364 |> Keyword.new() 265 - |> then(&{:custom, {Validators.Integer, [&1]}}) 365 + |> Validators.integer() 266 366 |> maybe_default(field) 267 367 end 268 368 |> then( ··· 284 384 |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end) 285 385 |> then(&Validators.array(inner_schema, &1)) 286 386 |> then(&Macro.escape/1) 387 + # TODO: we should be able to unquote this now... 287 388 # Can't unquote the inner_schema beforehand as that would risk evaluating `get_schema`s which don't exist yet. 288 389 # There's probably a better way to do this lol. 289 390 |> then(fn {:custom, {:{}, c, [Validators.Array, :validate, [quoted_inner_schema | args]]}} -> ··· 341 442 |> Atex.NSID.expand_possible_fragment_shorthand(ref) 342 443 |> Atex.NSID.to_atom_with_fragment() 343 444 344 - {quote do 345 - unquote(nsid).get_schema(unquote(fragment)) 346 - end, 347 - quote do 348 - unquote(nsid).unquote(fragment)() 349 - end} 445 + { 446 + Macro.escape(Validators.lazy_ref(nsid, fragment)), 447 + quote do 448 + unquote(nsid).unquote(fragment)() 449 + end 450 + } 350 451 end 351 452 352 453 defp field_to_schema(%{type: "union", refs: refs}, nsid) do ··· 362 463 |> Atex.NSID.expand_possible_fragment_shorthand(ref) 363 464 |> Atex.NSID.to_atom_with_fragment() 364 465 365 - {quote do 366 - unquote(nsid).get_schema(unquote(fragment)) 367 - end, 368 - quote do 369 - unquote(nsid).unquote(fragment)() 370 - end} 466 + { 467 + Macro.escape(Validators.lazy_ref(nsid, fragment)), 468 + quote do 469 + unquote(nsid).unquote(fragment)() 470 + end 471 + } 371 472 end) 372 473 |> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} -> 373 474 {[quoted_schema | schemas], [quoted_type | types]}
+5
lib/atex/lexicon/validators.ex
··· 81 81 } 82 82 end 83 83 84 + @spec lazy_ref(module(), atom()) :: Peri.schema() 85 + def lazy_ref(module, schema_name) do 86 + {:custom, {module, schema_name, []}} 87 + end 88 + 84 89 @spec boolean_validate(boolean(), String.t(), keyword() | map()) :: 85 90 Peri.validation_result() 86 91 def boolean_validate(success?, error_message, context \\ []) do
+2 -2
lib/atex/lexicon/validators/array.ex
··· 4 4 @option_keys [:min_length, :max_length] 5 5 6 6 # Needs type input 7 - @spec validate(Peri.schema_def(), term(), list(option())) :: Peri.validation_result() 8 - def validate(inner_type, value, options) when is_list(value) do 7 + @spec validate(term(), Peri.schema_def(), list(option())) :: Peri.validation_result() 8 + def validate(value, inner_type, options) when is_list(value) do 9 9 # TODO: validate inner_type with Peri to make sure it's correct? 10 10 11 11 options