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