An Elixir toolkit for the AT Protocol. hexdocs.pm/atex
elixir bluesky atproto decentralization
25
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(deflexicon): correctly generate `from_json` methods for ref & union inputs/outputs

+398 -4
+2
CHANGELOG.md
··· 42 42 43 43 - Fix a problem where generated `%<LexiconId>.Params` structs could not be 44 44 passed to an XRPC call due to not having the Enumerable protocol implemented. 45 + - Correctly generate `Input`/`Output` submodules with `from_json` methods for 46 + queries and procedures that use `ref` or `union` types. 45 47 46 48 ## [0.7.1] - 2026-02-06 47 49
+57 -4
lib/atex/lexicon.ex
··· 240 240 defstruct unquote(struct_keys) 241 241 242 242 def from_json(json) do 243 - case apply(unquote(schema_module), unquote(atomise(def_name)), [json]) do 243 + case apply(unquote(schema_module), unquote(atomise(Recase.to_snake(def_name))), [json]) do 244 244 {:ok, map} -> {:ok, struct(__MODULE__, map)} 245 245 err -> err 246 246 end ··· 440 440 ] 441 441 end 442 442 443 + defp def_to_schema(nsid, def_name, %{type: "ref", ref: ref}) do 444 + target_module = 445 + nsid 446 + |> Atex.NSID.expand_possible_fragment_shorthand(ref) 447 + |> ref_to_module() 448 + 449 + {quoted_schema, quoted_type} = field_to_schema(%{type: "ref", ref: ref}, nsid) 450 + 451 + quoted_struct = 452 + quote do 453 + def from_json(json), do: unquote(target_module).from_json(json) 454 + end 455 + 456 + [{atomise(def_name), quoted_schema, quoted_type, quoted_struct}] 457 + end 458 + 459 + defp def_to_schema(nsid, def_name, %{type: "union", refs: refs}) do 460 + target_modules = 461 + Enum.map(refs, fn ref -> 462 + nsid 463 + |> Atex.NSID.expand_possible_fragment_shorthand(ref) 464 + |> ref_to_module() 465 + end) 466 + 467 + {quoted_schema, quoted_type} = field_to_schema(%{type: "union", refs: refs}, nsid) 468 + 469 + quoted_struct = 470 + quote do 471 + def from_json(json) do 472 + Enum.find_value(unquote(target_modules), {:error, :no_matching_type}, fn mod -> 473 + case mod.from_json(json) do 474 + {:ok, _} = ok -> ok 475 + _ -> nil 476 + end 477 + end) 478 + end 479 + end 480 + 481 + [{atomise(def_name), quoted_schema, quoted_type, quoted_struct}] 482 + end 483 + 443 484 defp def_to_schema(nsid, def_name, %{type: type} = def) 444 485 when type in [ 445 486 "blob", ··· 449 490 "string", 450 491 "bytes", 451 492 "cid-link", 452 - "unknown", 453 - "ref", 454 - "union" 493 + "unknown" 455 494 ] do 456 495 {quoted_schema, quoted_type} = field_to_schema(def, nsid) 457 496 [{atomise(def_name), quoted_schema, quoted_type}] ··· 657 696 658 697 defp atomise(x) when is_atom(x), do: x 659 698 defp atomise(x) when is_binary(x), do: String.to_atom(x) 699 + 700 + # Resolves a fully-expanded NSID (possibly with a `#fragment`) to the 701 + # Elixir module atom that `deflexicon` generates for it. When the fragment is 702 + # `main` (or absent), the module is the root NSID module. Otherwise it is a 703 + # PascalCase-named submodule of the root NSID module. 704 + defp ref_to_module(expanded_nsid) do 705 + {nsid_atom, fragment} = Atex.NSID.to_atom_with_fragment(expanded_nsid) 706 + 707 + if fragment == :main do 708 + nsid_atom 709 + else 710 + Module.concat(nsid_atom, Recase.to_pascal(to_string(fragment))) 711 + end 712 + end 660 713 661 714 defp join_with_pipe(list) when is_list(list) do 662 715 [piped] = do_join_with_pipe(list)
+4
mix.exs
··· 10 10 app: :atex, 11 11 version: @version, 12 12 elixir: "~> 1.18", 13 + elixirc_paths: elixirc_paths(Mix.env()), 13 14 start_permanent: Mix.env() == :prod, 14 15 deps: deps(), 15 16 name: "atex", ··· 18 19 docs: docs() 19 20 ] 20 21 end 22 + 23 + defp elixirc_paths(:test), do: ["lib", "test/support"] 24 + defp elixirc_paths(_), do: ["lib"] 21 25 22 26 def application do 23 27 [
+138
test/atex/lexicon_test.exs
··· 1 + defmodule Atex.LexiconTest do 2 + use ExUnit.Case, async: true 3 + 4 + # Fixture modules are defined in test/support/lexicon_fixtures.ex and 5 + # compiled before tests run via the :test elixirc_paths config in mix.exs. 6 + 7 + # --------------------------------------------------------------------------- 8 + # Tests: ref-typed procedure input (local ref) 9 + # --------------------------------------------------------------------------- 10 + 11 + describe "procedure with local ref-typed input" do 12 + test "generates an Input submodule" do 13 + assert Code.ensure_loaded?(Lexicon.Test.CreatePost.Input) 14 + end 15 + 16 + test "Input submodule exports from_json/1" do 17 + assert function_exported?(Lexicon.Test.CreatePost.Input, :from_json, 1) 18 + end 19 + 20 + test "from_json/1 succeeds for valid data" do 21 + assert {:ok, result} = Lexicon.Test.CreatePost.Input.from_json(%{"text" => "hello"}) 22 + assert result.text == "hello" 23 + end 24 + 25 + test "from_json/1 returns error for invalid data" do 26 + assert {:error, _} = Lexicon.Test.CreatePost.Input.from_json(%{}) 27 + end 28 + end 29 + 30 + # --------------------------------------------------------------------------- 31 + # Tests: ref-typed query output (local ref) 32 + # --------------------------------------------------------------------------- 33 + 34 + describe "query with local ref-typed output" do 35 + test "generates an Output submodule" do 36 + assert Code.ensure_loaded?(Lexicon.Test.GetPost.Output) 37 + end 38 + 39 + test "Output submodule exports from_json/1" do 40 + assert function_exported?(Lexicon.Test.GetPost.Output, :from_json, 1) 41 + end 42 + 43 + test "from_json/1 succeeds for valid data" do 44 + assert {:ok, result} = 45 + Lexicon.Test.GetPost.Output.from_json(%{ 46 + "uri" => "at://did:plc:abc/app.bsky.feed.post/123" 47 + }) 48 + 49 + assert result.uri == "at://did:plc:abc/app.bsky.feed.post/123" 50 + end 51 + 52 + test "from_json/1 returns error for invalid data" do 53 + assert {:error, _} = Lexicon.Test.GetPost.Output.from_json(%{}) 54 + end 55 + end 56 + 57 + # --------------------------------------------------------------------------- 58 + # Tests: ref-typed procedure input (cross-NSID ref targeting a `main` def) 59 + # --------------------------------------------------------------------------- 60 + 61 + describe "procedure with cross-NSID ref-typed input" do 62 + test "generates an Input submodule" do 63 + assert Code.ensure_loaded?(Lexicon.Test.CreateProfile.Input) 64 + end 65 + 66 + test "Input submodule exports from_json/1" do 67 + assert function_exported?(Lexicon.Test.CreateProfile.Input, :from_json, 1) 68 + end 69 + 70 + test "from_json/1 delegates to the referenced module" do 71 + assert {:ok, result} = 72 + Lexicon.Test.CreateProfile.Input.from_json(%{"did" => "did:plc:abc"}) 73 + 74 + assert result.did == "did:plc:abc" 75 + end 76 + 77 + test "from_json/1 returns error when referenced module rejects data" do 78 + assert {:error, _} = Lexicon.Test.CreateProfile.Input.from_json(%{}) 79 + end 80 + end 81 + 82 + # --------------------------------------------------------------------------- 83 + # Tests: union-typed procedure input (local refs) 84 + # --------------------------------------------------------------------------- 85 + 86 + describe "procedure with union-typed input" do 87 + test "generates an Input submodule" do 88 + assert Code.ensure_loaded?(Lexicon.Test.CreateUnion.Input) 89 + end 90 + 91 + test "Input submodule exports from_json/1" do 92 + assert function_exported?(Lexicon.Test.CreateUnion.Input, :from_json, 1) 93 + end 94 + 95 + test "from_json/1 succeeds for the first union member" do 96 + assert {:ok, result} = Lexicon.Test.CreateUnion.Input.from_json(%{"text" => "hello"}) 97 + assert result.text == "hello" 98 + end 99 + 100 + test "from_json/1 succeeds for the second union member" do 101 + assert {:ok, result} = Lexicon.Test.CreateUnion.Input.from_json(%{"error" => "bad"}) 102 + assert result.error == "bad" 103 + end 104 + 105 + test "from_json/1 returns :no_matching_type when no member matches" do 106 + assert {:error, :no_matching_type} = 107 + Lexicon.Test.CreateUnion.Input.from_json(%{"unknown" => "field"}) 108 + end 109 + end 110 + 111 + # --------------------------------------------------------------------------- 112 + # Tests: union-typed query output (cross-NSID refs) 113 + # --------------------------------------------------------------------------- 114 + 115 + describe "query with union-typed output (cross-NSID)" do 116 + test "generates an Output submodule" do 117 + assert Code.ensure_loaded?(Lexicon.Test.GetUnion.Output) 118 + end 119 + 120 + test "Output submodule exports from_json/1" do 121 + assert function_exported?(Lexicon.Test.GetUnion.Output, :from_json, 1) 122 + end 123 + 124 + test "from_json/1 succeeds for the first union member" do 125 + assert {:ok, result} = Lexicon.Test.GetUnion.Output.from_json(%{"did" => "did:plc:abc"}) 126 + assert result.did == "did:plc:abc" 127 + end 128 + 129 + test "from_json/1 succeeds for the second union member" do 130 + assert {:ok, result} = Lexicon.Test.GetUnion.Output.from_json(%{"error" => "oops"}) 131 + assert result.error == "oops" 132 + end 133 + 134 + test "from_json/1 returns :no_matching_type when no member matches" do 135 + assert {:error, :no_matching_type} = Lexicon.Test.GetUnion.Output.from_json(%{}) 136 + end 137 + end 138 + end
+197
test/support/lexicon_fixtures.ex
··· 1 + # Lexicon fixture modules for Atex.LexiconTest. 2 + # 3 + # Defined in test/support so they are compiled before tests run, ensuring 4 + # Code.ensure_loaded? and function_exported? return correct results even when 5 + # Atex.LexiconTest runs with async: true. 6 + 7 + # Standalone record used as the target of cross-NSID ref/union tests. 8 + # NSID "lexicon.test.profileView" -> Lexicon.Test.ProfileView 9 + defmodule Lexicon.Test.ProfileView do 10 + @moduledoc false 11 + use Atex.Lexicon 12 + 13 + deflexicon(%{ 14 + "lexicon" => 1, 15 + "id" => "lexicon.test.profileView", 16 + "defs" => %{ 17 + "main" => %{ 18 + "type" => "object", 19 + "required" => ["did"], 20 + "properties" => %{ 21 + "did" => %{"type" => "string"} 22 + } 23 + } 24 + } 25 + }) 26 + end 27 + 28 + # Standalone record used as the second member of cross-NSID union tests. 29 + # NSID "lexicon.test.errorView" -> Lexicon.Test.ErrorView 30 + defmodule Lexicon.Test.ErrorView do 31 + @moduledoc false 32 + use Atex.Lexicon 33 + 34 + deflexicon(%{ 35 + "lexicon" => 1, 36 + "id" => "lexicon.test.errorView", 37 + "defs" => %{ 38 + "main" => %{ 39 + "type" => "object", 40 + "required" => ["error"], 41 + "properties" => %{ 42 + "error" => %{"type" => "string"} 43 + } 44 + } 45 + } 46 + }) 47 + end 48 + 49 + # Procedure whose input.schema is a local `ref` to a sibling def. 50 + # NSID "lexicon.test.createPost" -> Lexicon.Test.CreatePost 51 + defmodule Lexicon.Test.CreatePost do 52 + @moduledoc false 53 + use Atex.Lexicon 54 + 55 + deflexicon(%{ 56 + "lexicon" => 1, 57 + "id" => "lexicon.test.createPost", 58 + "defs" => %{ 59 + "main" => %{ 60 + "type" => "procedure", 61 + "input" => %{ 62 + "encoding" => "application/json", 63 + "schema" => %{"type" => "ref", "ref" => "#postInput"} 64 + }, 65 + "output" => %{ 66 + "encoding" => "application/json", 67 + "schema" => %{ 68 + "type" => "object", 69 + "required" => ["uri"], 70 + "properties" => %{"uri" => %{"type" => "string"}} 71 + } 72 + } 73 + }, 74 + "postInput" => %{ 75 + "type" => "object", 76 + "required" => ["text"], 77 + "properties" => %{ 78 + "text" => %{"type" => "string"} 79 + } 80 + } 81 + } 82 + }) 83 + end 84 + 85 + # Query whose output.schema is a local `ref` to a sibling def. 86 + # NSID "lexicon.test.getPost" -> Lexicon.Test.GetPost 87 + defmodule Lexicon.Test.GetPost do 88 + @moduledoc false 89 + use Atex.Lexicon 90 + 91 + deflexicon(%{ 92 + "lexicon" => 1, 93 + "id" => "lexicon.test.getPost", 94 + "defs" => %{ 95 + "main" => %{ 96 + "type" => "query", 97 + "output" => %{ 98 + "encoding" => "application/json", 99 + "schema" => %{"type" => "ref", "ref" => "#postView"} 100 + } 101 + }, 102 + "postView" => %{ 103 + "type" => "object", 104 + "required" => ["uri"], 105 + "properties" => %{ 106 + "uri" => %{"type" => "string"} 107 + } 108 + } 109 + } 110 + }) 111 + end 112 + 113 + # Procedure whose input.schema is a cross-NSID `ref` targeting a `main` def. 114 + # NSID "lexicon.test.createProfile" -> Lexicon.Test.CreateProfile 115 + defmodule Lexicon.Test.CreateProfile do 116 + @moduledoc false 117 + use Atex.Lexicon 118 + 119 + deflexicon(%{ 120 + "lexicon" => 1, 121 + "id" => "lexicon.test.createProfile", 122 + "defs" => %{ 123 + "main" => %{ 124 + "type" => "procedure", 125 + "input" => %{ 126 + "encoding" => "application/json", 127 + "schema" => %{"type" => "ref", "ref" => "lexicon.test.profileView"} 128 + } 129 + } 130 + } 131 + }) 132 + end 133 + 134 + # Procedure whose input.schema is a `union` of two local refs. 135 + # NSID "lexicon.test.createUnion" -> Lexicon.Test.CreateUnion 136 + defmodule Lexicon.Test.CreateUnion do 137 + @moduledoc false 138 + use Atex.Lexicon 139 + 140 + deflexicon(%{ 141 + "lexicon" => 1, 142 + "id" => "lexicon.test.createUnion", 143 + "defs" => %{ 144 + "main" => %{ 145 + "type" => "procedure", 146 + "input" => %{ 147 + "encoding" => "application/json", 148 + "schema" => %{ 149 + "type" => "union", 150 + "refs" => ["#postInput", "#errorInput"] 151 + } 152 + } 153 + }, 154 + "postInput" => %{ 155 + "type" => "object", 156 + "required" => ["text"], 157 + "properties" => %{ 158 + "text" => %{"type" => "string"} 159 + } 160 + }, 161 + "errorInput" => %{ 162 + "type" => "object", 163 + "required" => ["error"], 164 + "properties" => %{ 165 + "error" => %{"type" => "string"} 166 + } 167 + } 168 + } 169 + }) 170 + end 171 + 172 + # Query whose output.schema is a `union` of two cross-NSID refs. 173 + # NSID "lexicon.test.getUnion" -> Lexicon.Test.GetUnion 174 + defmodule Lexicon.Test.GetUnion do 175 + @moduledoc false 176 + use Atex.Lexicon 177 + 178 + deflexicon(%{ 179 + "lexicon" => 1, 180 + "id" => "lexicon.test.getUnion", 181 + "defs" => %{ 182 + "main" => %{ 183 + "type" => "query", 184 + "output" => %{ 185 + "encoding" => "application/json", 186 + "schema" => %{ 187 + "type" => "union", 188 + "refs" => [ 189 + "lexicon.test.profileView", 190 + "lexicon.test.errorView" 191 + ] 192 + } 193 + } 194 + } 195 + } 196 + }) 197 + end