···66and this project adheres to
77[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8899-<!-- ## [Unreleased] -->
99+## [Unreleased]
1010+1111+### Breking Changes
1212+1313+- `deflexicon` now converts all def names to be in snake_case instead of the
1414+ casing as written the lexicon.
1515+1616+### Added
1717+1818+- `deflexicon` now emits structs for records, objects, queries, and procedures.
10191120## [0.5.0] - 2025-10-11
1221
+83-11
lib/atex/lexicon.ex
···11defmodule Atex.Lexicon do
22- @moduledoc """
33- Provide `deflexicon` macro for defining a module with types and schemas from an entire lexicon definition.
44-55- Should it also define structs, with functions to convert from input case to snake case?
66- """
77-82 alias Atex.Lexicon.Validators
93104 defmacro __using__(_opts) do
···159 end
1610 end
17111212+ @doc """
1313+ Defines a lexicon module from a JSON lexicon definition.
1414+1515+ The `deflexicon` macro processes the provided lexicon map (typically loaded
1616+ from a JSON file) and generates:
1717+1818+ - **Typespecs** for each definition, exposing a `t/0` type for the main
1919+ definition and named types for any additional definitions.
2020+ - **`Peri` schemas** via `defschema/2` for runtime validation of data.
2121+ - **Structs** for object and record definitions, with `@enforce_keys` ensuring
2222+ required fields are present.
2323+ - For **queries** and **procedures**, it creates structs for `params`,
2424+ `input`, and `output` when those sections exist in the lexicon. It also
2525+ generates a top‑level struct that aggregates `params` and `input` (when
2626+ applicable); this struct is used by the XRPC client to locate the
2727+ appropriate output struct.
2828+2929+ If a procedure doesn't have a schema for a JSON body specified as it's input,
3030+ the top-level struct will instead have a `raw_input` field, allowing for
3131+ miscellaneous bodies such as a binary blob.
3232+3333+ The generated structs also implement the `JSON.Encoder` and `Jason.Encoder`
3434+ protocols (the latter currently present for compatibility), as well as a
3535+ `from_json` function which is used to validate an input map - e.g. from a JSON
3636+ HTTP response - and turn it into a struct.
3737+3838+ ## Example
3939+4040+ deflexicon(%{
4141+ "lexicon" => 1,
4242+ "id" => "com.ovyerus.testing",
4343+ "defs" => %{
4444+ "main" => %{
4545+ "type" => "record",
4646+ "key" => "tid",
4747+ "record" => %{
4848+ "type" => "object",
4949+ "required" => ["foobar"],
5050+ "properties" => %{ "foobar" => %{ "type" => "string" } }
5151+ }
5252+ }
5353+ }
5454+ })
5555+5656+ The macro expands to following code (truncated for brevity):
5757+5858+ @type main() :: %{required(:foobar) => String.t(), optional(:"$type") => String.t()}
5959+ @type t() :: %{required(:foobar) => String.t(), optional(:"$type") => String.t()}
6060+6161+ defschema(:main, %{
6262+ foobar: {:required, {:custom, {Atex.Lexicon.Validators.String, :validate, [[]]}}},
6363+ "$type": {{:literal, "com.ovyerus.testing"}, {:default, "com.ovyerus.testing"}}
6464+ })
6565+6666+ @enforce_keys [:foobar]
6767+ defstruct foobar: nil, "$type": "com.ovyerus.testing"
6868+6969+ def from_json(json) do
7070+ case apply(Com.Ovyerus.Testing, :main, [json]) do
7171+ {:ok, map} -> {:ok, struct(__MODULE__, map)}
7272+ err -> err
7373+ end
7474+ end
7575+7676+ The generated module can be used directly with `Atex.XRPC` functions, allowing
7777+ type‑safe construction of requests and automatic decoding of responses.
7878+ """
1879 defmacro deflexicon(lexicon) do
1980 # Better way to get the real map, without having to eval? (custom function to compose one from quoted?)
2081 lexicon =
···2384 |> elem(0)
2485 |> then(&Recase.Enumerable.atomize_keys/1)
2586 |> then(&Atex.Lexicon.Schema.lexicon!/1)
2626-2727- lexicon_id = Atex.NSID.to_atom(lexicon.id)
28872988 defs =
3089 lexicon.defs
···58117 end
5911860119 quote do
6161- @type unquote(schema_key)() :: unquote(quoted_type)
120120+ @type unquote(Recase.to_snake(schema_key))() :: unquote(quoted_type)
62121 unquote(identity_type)
631226464- defschema unquote(schema_key), unquote(quoted_schema)
123123+ defschema unquote(Recase.to_snake(schema_key)), unquote(quoted_schema)
6512466125 unquote(struct_def)
67126 end
68127 end)
6912870129 quote do
7171- def id, do: unquote(lexicon_id)
130130+ def id, do: unquote(lexicon.id)
7213173132 unquote_splicing(defs)
74133 end
···173232 key not in required && key not in nullable && key != "$type"
174233 end)
175234235235+ schema_module = Atex.NSID.to_atom(nsid)
236236+176237 quoted_struct =
177238 quote do
178239 @enforce_keys unquote(enforced_keys)
179240 defstruct unquote(struct_keys)
241241+242242+ def from_json(json) do
243243+ case apply(unquote(schema_module), unquote(atomise(def_name)), [json]) do
244244+ {:ok, map} -> {:ok, struct(__MODULE__, map)}
245245+ err -> err
246246+ end
247247+ end
180248181249 defimpl JSON.Encoder do
182250 @optional_if_nil_keys unquote(optional_if_nil_keys)
···517585 |> Atex.NSID.expand_possible_fragment_shorthand(ref)
518586 |> Atex.NSID.to_atom_with_fragment()
519587588588+ fragment = Recase.to_snake(fragment)
589589+520590 {
521591 Macro.escape(Validators.lazy_ref(nsid, fragment)),
522592 quote do
···537607 nsid
538608 |> Atex.NSID.expand_possible_fragment_shorthand(ref)
539609 |> Atex.NSID.to_atom_with_fragment()
610610+611611+ fragment = Recase.to_snake(fragment)
540612541613 {
542614 Macro.escape(Validators.lazy_ref(nsid, fragment)),
+1
lib/atex/lexicon/validators/string.ex
···5656 # TODO: is there a regex provided by the lexicon docs/somewhere?
5757 try do
5858 Multiformats.CID.decode(value)
5959+ :ok
5960 rescue
6061 _ -> {:error, "should be a valid CID", []}
6162 end