···42424343- Fix a problem where generated `%<LexiconId>.Params` structs could not be
4444 passed to an XRPC call due to not having the Enumerable protocol implemented.
4545+- Correctly generate `Input`/`Output` submodules with `from_json` methods for
4646+ queries and procedures that use `ref` or `union` types.
45474648## [0.7.1] - 2026-02-06
4749
+57-4
lib/atex/lexicon.ex
···240240 defstruct unquote(struct_keys)
241241242242 def from_json(json) do
243243- case apply(unquote(schema_module), unquote(atomise(def_name)), [json]) do
243243+ case apply(unquote(schema_module), unquote(atomise(Recase.to_snake(def_name))), [json]) do
244244 {:ok, map} -> {:ok, struct(__MODULE__, map)}
245245 err -> err
246246 end
···440440 ]
441441 end
442442443443+ defp def_to_schema(nsid, def_name, %{type: "ref", ref: ref}) do
444444+ target_module =
445445+ nsid
446446+ |> Atex.NSID.expand_possible_fragment_shorthand(ref)
447447+ |> ref_to_module()
448448+449449+ {quoted_schema, quoted_type} = field_to_schema(%{type: "ref", ref: ref}, nsid)
450450+451451+ quoted_struct =
452452+ quote do
453453+ def from_json(json), do: unquote(target_module).from_json(json)
454454+ end
455455+456456+ [{atomise(def_name), quoted_schema, quoted_type, quoted_struct}]
457457+ end
458458+459459+ defp def_to_schema(nsid, def_name, %{type: "union", refs: refs}) do
460460+ target_modules =
461461+ Enum.map(refs, fn ref ->
462462+ nsid
463463+ |> Atex.NSID.expand_possible_fragment_shorthand(ref)
464464+ |> ref_to_module()
465465+ end)
466466+467467+ {quoted_schema, quoted_type} = field_to_schema(%{type: "union", refs: refs}, nsid)
468468+469469+ quoted_struct =
470470+ quote do
471471+ def from_json(json) do
472472+ Enum.find_value(unquote(target_modules), {:error, :no_matching_type}, fn mod ->
473473+ case mod.from_json(json) do
474474+ {:ok, _} = ok -> ok
475475+ _ -> nil
476476+ end
477477+ end)
478478+ end
479479+ end
480480+481481+ [{atomise(def_name), quoted_schema, quoted_type, quoted_struct}]
482482+ end
483483+443484 defp def_to_schema(nsid, def_name, %{type: type} = def)
444485 when type in [
445486 "blob",
···449490 "string",
450491 "bytes",
451492 "cid-link",
452452- "unknown",
453453- "ref",
454454- "union"
493493+ "unknown"
455494 ] do
456495 {quoted_schema, quoted_type} = field_to_schema(def, nsid)
457496 [{atomise(def_name), quoted_schema, quoted_type}]
···657696658697 defp atomise(x) when is_atom(x), do: x
659698 defp atomise(x) when is_binary(x), do: String.to_atom(x)
699699+700700+ # Resolves a fully-expanded NSID (possibly with a `#fragment`) to the
701701+ # Elixir module atom that `deflexicon` generates for it. When the fragment is
702702+ # `main` (or absent), the module is the root NSID module. Otherwise it is a
703703+ # PascalCase-named submodule of the root NSID module.
704704+ defp ref_to_module(expanded_nsid) do
705705+ {nsid_atom, fragment} = Atex.NSID.to_atom_with_fragment(expanded_nsid)
706706+707707+ if fragment == :main do
708708+ nsid_atom
709709+ else
710710+ Module.concat(nsid_atom, Recase.to_pascal(to_string(fragment)))
711711+ end
712712+ end
660713661714 defp join_with_pipe(list) when is_list(list) do
662715 [piped] = do_join_with_pipe(list)
···11+defmodule Atex.LexiconTest do
22+ use ExUnit.Case, async: true
33+44+ # Fixture modules are defined in test/support/lexicon_fixtures.ex and
55+ # compiled before tests run via the :test elixirc_paths config in mix.exs.
66+77+ # ---------------------------------------------------------------------------
88+ # Tests: ref-typed procedure input (local ref)
99+ # ---------------------------------------------------------------------------
1010+1111+ describe "procedure with local ref-typed input" do
1212+ test "generates an Input submodule" do
1313+ assert Code.ensure_loaded?(Lexicon.Test.CreatePost.Input)
1414+ end
1515+1616+ test "Input submodule exports from_json/1" do
1717+ assert function_exported?(Lexicon.Test.CreatePost.Input, :from_json, 1)
1818+ end
1919+2020+ test "from_json/1 succeeds for valid data" do
2121+ assert {:ok, result} = Lexicon.Test.CreatePost.Input.from_json(%{"text" => "hello"})
2222+ assert result.text == "hello"
2323+ end
2424+2525+ test "from_json/1 returns error for invalid data" do
2626+ assert {:error, _} = Lexicon.Test.CreatePost.Input.from_json(%{})
2727+ end
2828+ end
2929+3030+ # ---------------------------------------------------------------------------
3131+ # Tests: ref-typed query output (local ref)
3232+ # ---------------------------------------------------------------------------
3333+3434+ describe "query with local ref-typed output" do
3535+ test "generates an Output submodule" do
3636+ assert Code.ensure_loaded?(Lexicon.Test.GetPost.Output)
3737+ end
3838+3939+ test "Output submodule exports from_json/1" do
4040+ assert function_exported?(Lexicon.Test.GetPost.Output, :from_json, 1)
4141+ end
4242+4343+ test "from_json/1 succeeds for valid data" do
4444+ assert {:ok, result} =
4545+ Lexicon.Test.GetPost.Output.from_json(%{
4646+ "uri" => "at://did:plc:abc/app.bsky.feed.post/123"
4747+ })
4848+4949+ assert result.uri == "at://did:plc:abc/app.bsky.feed.post/123"
5050+ end
5151+5252+ test "from_json/1 returns error for invalid data" do
5353+ assert {:error, _} = Lexicon.Test.GetPost.Output.from_json(%{})
5454+ end
5555+ end
5656+5757+ # ---------------------------------------------------------------------------
5858+ # Tests: ref-typed procedure input (cross-NSID ref targeting a `main` def)
5959+ # ---------------------------------------------------------------------------
6060+6161+ describe "procedure with cross-NSID ref-typed input" do
6262+ test "generates an Input submodule" do
6363+ assert Code.ensure_loaded?(Lexicon.Test.CreateProfile.Input)
6464+ end
6565+6666+ test "Input submodule exports from_json/1" do
6767+ assert function_exported?(Lexicon.Test.CreateProfile.Input, :from_json, 1)
6868+ end
6969+7070+ test "from_json/1 delegates to the referenced module" do
7171+ assert {:ok, result} =
7272+ Lexicon.Test.CreateProfile.Input.from_json(%{"did" => "did:plc:abc"})
7373+7474+ assert result.did == "did:plc:abc"
7575+ end
7676+7777+ test "from_json/1 returns error when referenced module rejects data" do
7878+ assert {:error, _} = Lexicon.Test.CreateProfile.Input.from_json(%{})
7979+ end
8080+ end
8181+8282+ # ---------------------------------------------------------------------------
8383+ # Tests: union-typed procedure input (local refs)
8484+ # ---------------------------------------------------------------------------
8585+8686+ describe "procedure with union-typed input" do
8787+ test "generates an Input submodule" do
8888+ assert Code.ensure_loaded?(Lexicon.Test.CreateUnion.Input)
8989+ end
9090+9191+ test "Input submodule exports from_json/1" do
9292+ assert function_exported?(Lexicon.Test.CreateUnion.Input, :from_json, 1)
9393+ end
9494+9595+ test "from_json/1 succeeds for the first union member" do
9696+ assert {:ok, result} = Lexicon.Test.CreateUnion.Input.from_json(%{"text" => "hello"})
9797+ assert result.text == "hello"
9898+ end
9999+100100+ test "from_json/1 succeeds for the second union member" do
101101+ assert {:ok, result} = Lexicon.Test.CreateUnion.Input.from_json(%{"error" => "bad"})
102102+ assert result.error == "bad"
103103+ end
104104+105105+ test "from_json/1 returns :no_matching_type when no member matches" do
106106+ assert {:error, :no_matching_type} =
107107+ Lexicon.Test.CreateUnion.Input.from_json(%{"unknown" => "field"})
108108+ end
109109+ end
110110+111111+ # ---------------------------------------------------------------------------
112112+ # Tests: union-typed query output (cross-NSID refs)
113113+ # ---------------------------------------------------------------------------
114114+115115+ describe "query with union-typed output (cross-NSID)" do
116116+ test "generates an Output submodule" do
117117+ assert Code.ensure_loaded?(Lexicon.Test.GetUnion.Output)
118118+ end
119119+120120+ test "Output submodule exports from_json/1" do
121121+ assert function_exported?(Lexicon.Test.GetUnion.Output, :from_json, 1)
122122+ end
123123+124124+ test "from_json/1 succeeds for the first union member" do
125125+ assert {:ok, result} = Lexicon.Test.GetUnion.Output.from_json(%{"did" => "did:plc:abc"})
126126+ assert result.did == "did:plc:abc"
127127+ end
128128+129129+ test "from_json/1 succeeds for the second union member" do
130130+ assert {:ok, result} = Lexicon.Test.GetUnion.Output.from_json(%{"error" => "oops"})
131131+ assert result.error == "oops"
132132+ end
133133+134134+ test "from_json/1 returns :no_matching_type when no member matches" do
135135+ assert {:error, :no_matching_type} = Lexicon.Test.GetUnion.Output.from_json(%{})
136136+ end
137137+ end
138138+end