A set of utilities for working with the AT Protocol in Elixir.
1defmodule 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 lexicon_id = Atex.NSID.to_atom(lexicon.id) 28 29 defs = 30 lexicon.defs 31 |> Enum.flat_map(fn {def_name, def} -> def_to_schema(lexicon.id, def_name, def) end) 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} -> 37 identity_type = 38 if schema_key == :main do 39 quote do 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 57 end 58 end 59 60 quote do 61 @type unquote(schema_key)() :: unquote(quoted_type) 62 unquote(identity_type) 63 64 defschema unquote(schema_key), unquote(quoted_schema) 65 66 unquote(struct_def) 67 end 68 end) 69 70 foo = 71 quote do 72 def id, do: unquote(lexicon_id) 73 74 unquote_splicing(defs) 75 end 76 77 if lexicon.id == "app.bsky.feed.post" do 78 IO.puts("-----") 79 foo |> Macro.expand(__ENV__) |> Macro.to_string() |> IO.puts() 80 end 81 82 foo 83 end 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 93 @spec def_to_schema(nsid :: String.t(), def_name :: String.t(), lexicon_def :: map()) :: 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 ) 107 108 defp def_to_schema(nsid, def_name, %{type: "record", record: record}) do 109 # TODO: record rkey format validator 110 def_to_schema(nsid, def_name, record) 111 end 112 113 # TODO: need to spit out an extra 'branded' type with `$type` field, for use in union refs. 114 defp def_to_schema( 115 nsid, 116 def_name, 117 %{ 118 type: "object", 119 properties: properties 120 } = def 121 ) do 122 required = Map.get(def, :required, []) 123 nullable = Map.get(def, :nullable, []) 124 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}")}] 165 166 enforced_keys = properties |> Map.keys() |> Enum.filter(&(to_string(&1) in required)) 167 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) 176 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) 191 end 192 end 193 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}] 208 end 209 210 # TODO: validating errors? 211 defp def_to_schema(nsid, _def_name, %{type: "query"} = def) do 212 params = 213 if def[:parameters] do 214 [schema] = 215 def_to_schema(nsid, "params", %{ 216 type: "object", 217 required: Map.get(def.parameters, :required, []), 218 properties: def.parameters.properties 219 }) 220 221 schema 222 end 223 224 output = 225 if def[:output] && def.output[:schema] do 226 [schema] = def_to_schema(nsid, "output", def.output.schema) 227 schema 228 end 229 230 [params, output] 231 |> Enum.reject(&is_nil/1) 232 end 233 234 defp def_to_schema(nsid, _def_name, %{type: "procedure"} = def) do 235 # TODO: better keys for these 236 params = 237 if def[:parameters] do 238 [schema] = 239 def_to_schema(nsid, "params", %{ 240 type: "object", 241 required: Map.get(def.parameters, :required, []), 242 properties: def.parameters.properties 243 }) 244 245 schema 246 end 247 248 output = 249 if def[:output] && def.output[:schema] do 250 [schema] = def_to_schema(nsid, "output", def.output.schema) 251 schema 252 end 253 254 input = 255 if def[:input] && def.input[:schema] do 256 [schema] = def_to_schema(nsid, "input", def.input.schema) 257 schema 258 end 259 260 [params, output, input] 261 |> Enum.reject(&is_nil/1) 262 end 263 264 defp def_to_schema(nsid, _def_name, %{type: "subscription"} = def) do 265 params = 266 if def[:parameters] do 267 [schema] = 268 def_to_schema(nsid, "params", %{ 269 type: "object", 270 required: Map.get(def.parameters, :required, []), 271 properties: def.parameters.properties 272 }) 273 274 schema 275 end 276 277 message = 278 if def[:message] do 279 [schema] = def_to_schema(nsid, "message", def.message.schema) 280 schema 281 end 282 283 [params, message] 284 |> Enum.reject(&is_nil/1) 285 end 286 287 defp def_to_schema(_nsid, def_name, %{type: "token"}) do 288 # TODO: make it a validator that expects the nsid + key. 289 [ 290 { 291 atomise(def_name), 292 :string, 293 quote do 294 String.t() 295 end 296 } 297 ] 298 end 299 300 defp def_to_schema(nsid, def_name, %{type: type} = def) 301 when type in [ 302 "blob", 303 "array", 304 "boolean", 305 "integer", 306 "string", 307 "bytes", 308 "cid-link", 309 "unknown", 310 "ref", 311 "union" 312 ] do 313 {quoted_schema, quoted_type} = field_to_schema(def, nsid) 314 [{atomise(def_name), quoted_schema, quoted_type}] 315 end 316 317 @spec field_to_schema(field_def :: %{type: String.t()}, nsid :: String.t()) :: 318 {quoted_schema :: term(), quoted_typespec :: term()} 319 defp field_to_schema(%{type: "string"} = field, _nsid) do 320 fixed_schema = const_or_enum(field) 321 322 if fixed_schema do 323 maybe_default(fixed_schema, field) 324 else 325 field 326 |> Map.take([ 327 :format, 328 :maxLength, 329 :minLength, 330 :maxGraphemes, 331 :minGraphemes 332 ]) 333 |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end) 334 |> Validators.string() 335 |> maybe_default(field) 336 end 337 |> then( 338 &{Macro.escape(&1), 339 quote do 340 String.t() 341 end} 342 ) 343 end 344 345 defp field_to_schema(%{type: "boolean"} = field, _nsid) do 346 (const(field) || :boolean) 347 |> maybe_default(field) 348 |> then( 349 &{Macro.escape(&1), 350 quote do 351 boolean() 352 end} 353 ) 354 end 355 356 defp field_to_schema(%{type: "integer"} = field, _nsid) do 357 fixed_schema = const_or_enum(field) 358 359 if fixed_schema do 360 maybe_default(fixed_schema, field) 361 else 362 field 363 |> Map.take([:maximum, :minimum]) 364 |> Keyword.new() 365 |> Validators.integer() 366 |> maybe_default(field) 367 end 368 |> then( 369 &{ 370 Macro.escape(&1), 371 # TODO: turn into range definition based on maximum/minimum 372 quote do 373 integer() 374 end 375 } 376 ) 377 end 378 379 defp field_to_schema(%{type: "array", items: items} = field, nsid) do 380 {inner_schema, inner_type} = field_to_schema(items, nsid) 381 382 field 383 |> Map.take([:maxLength, :minLength]) 384 |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end) 385 |> then(&Validators.array(inner_schema, &1)) 386 |> then(&Macro.escape/1) 387 # TODO: we should be able to unquote this now... 388 # Can't unquote the inner_schema beforehand as that would risk evaluating `get_schema`s which don't exist yet. 389 # There's probably a better way to do this lol. 390 |> then(fn {:custom, {:{}, c, [Validators.Array, :validate, [quoted_inner_schema | args]]}} -> 391 {inner_schema, _} = Code.eval_quoted(quoted_inner_schema) 392 {:custom, {:{}, c, [Validators.Array, :validate, [inner_schema | args]]}} 393 end) 394 |> then( 395 &{&1, 396 quote do 397 list(unquote(inner_type)) 398 end} 399 ) 400 end 401 402 defp field_to_schema(%{type: "blob"} = field, _nsid) do 403 field 404 |> Map.take([:accept, :maxSize]) 405 |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end) 406 |> Validators.blob() 407 |> then( 408 &{Macro.escape(&1), 409 quote do 410 Validators.blob() 411 end} 412 ) 413 end 414 415 defp field_to_schema(%{type: "bytes"} = field, _nsid) do 416 field 417 |> Map.take([:maxLength, :minLength]) 418 |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end) 419 |> Validators.bytes() 420 |> then( 421 &{Macro.escape(&1), 422 quote do 423 Validators.bytes() 424 end} 425 ) 426 end 427 428 defp field_to_schema(%{type: "cid-link"}, _nsid) do 429 Validators.cid_link() 430 |> then( 431 &{Macro.escape(&1), 432 quote do 433 Validators.cid_link() 434 end} 435 ) 436 end 437 438 # TODO: do i need to make sure these two deal with brands? Check objects in atp.tools 439 defp field_to_schema(%{type: "ref", ref: ref}, nsid) do 440 {nsid, fragment} = 441 nsid 442 |> Atex.NSID.expand_possible_fragment_shorthand(ref) 443 |> Atex.NSID.to_atom_with_fragment() 444 445 { 446 Macro.escape(Validators.lazy_ref(nsid, fragment)), 447 quote do 448 unquote(nsid).unquote(fragment)() 449 end 450 } 451 end 452 453 defp field_to_schema(%{type: "union", refs: refs}, nsid) do 454 if refs == [] do 455 {quote do 456 {:oneof, []} 457 end, nil} 458 else 459 refs 460 |> Enum.map(fn ref -> 461 {nsid, fragment} = 462 nsid 463 |> Atex.NSID.expand_possible_fragment_shorthand(ref) 464 |> Atex.NSID.to_atom_with_fragment() 465 466 { 467 Macro.escape(Validators.lazy_ref(nsid, fragment)), 468 quote do 469 unquote(nsid).unquote(fragment)() 470 end 471 } 472 end) 473 |> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} -> 474 {[quoted_schema | schemas], [quoted_type | types]} 475 end) 476 |> then(fn {schemas, types} -> 477 {quote do 478 {:oneof, unquote(schemas)} 479 end, 480 quote do 481 unquote(join_with_pipe(types)) 482 end} 483 end) 484 end 485 end 486 487 # TODO: apparently should be a data object, not a primitive? 488 defp field_to_schema(%{type: "unknown"}, _nsid) do 489 {:any, 490 quote do 491 term() 492 end} 493 end 494 495 defp field_to_schema(_field_def, _nsid), do: {nil, nil} 496 497 defp maybe_default(schema, field) do 498 if field[:default] != nil, 499 do: {schema, {:default, field.default}}, 500 else: schema 501 end 502 503 defp const_or_enum(field), do: const(field) || enum(field) 504 505 defp const(%{const: value}), do: {:literal, value} 506 defp const(_), do: nil 507 508 defp enum(%{enum: values}), do: {:enum, values} 509 defp enum(_), do: nil 510 511 defp atomise(x) when is_atom(x), do: x 512 defp atomise(x) when is_binary(x), do: String.to_atom(x) 513 514 defp join_with_pipe(list) when is_list(list) do 515 [piped] = do_join_with_pipe(list) 516 piped 517 end 518 519 defp do_join_with_pipe([head]), do: [head] 520 defp do_join_with_pipe([head | tail]), do: [{:|, [], [head | do_join_with_pipe(tail)]}] 521 defp do_join_with_pipe([]), do: [] 522end