Skip to content

Commit 28303d8

Browse files
committed
feat: deflexicon macro for coverting Lexicons into runtime validation schemas
1 parent 5f5c37c commit 28303d8

File tree

17 files changed

+1164
-45
lines changed

17 files changed

+1164
-45
lines changed

.formatter.exs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Used by "mix format"
22
[
33
inputs: ["{mix,.formatter,.credo}.exs", "{config,lib,test}/**/*.{ex,exs}"],
4-
import_deps: [:typedstruct, :peri]
4+
import_deps: [:typedstruct, :peri],
5+
export: [
6+
locals_without_parens: [deflexicon: 1]
7+
]
58
]

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to
77
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

9-
<!-- ## [Unreleased] -->
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.
1016

1117
## [0.3.0] - 2025-06-29
1218

lib/atex/lexicon.ex

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
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

0 commit comments

Comments
 (0)