From 106ca24e65677f4df359c593e30bcad0407eea3c Mon Sep 17 00:00:00 2001 From: Ashlynne Mitchell Date: Wed, 25 Mar 2026 16:26:56 +1100 Subject: [PATCH] fix(deflexicon): correctly generate `from_json` methods for ref & union inputs/outputs --- CHANGELOG.md | 2 + lib/atex/lexicon.ex | 61 +++++++++- mix.exs | 4 + test/atex/lexicon_test.exs | 138 ++++++++++++++++++++++ test/support/lexicon_fixtures.ex | 197 +++++++++++++++++++++++++++++++ 5 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 test/atex/lexicon_test.exs create mode 100644 test/support/lexicon_fixtures.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index 2801198..99112c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,8 @@ and this project adheres to - Fix a problem where generated `%.Params` structs could not be passed to an XRPC call due to not having the Enumerable protocol implemented. +- Correctly generate `Input`/`Output` submodules with `from_json` methods for + queries and procedures that use `ref` or `union` types. ## [0.7.1] - 2026-02-06 diff --git a/lib/atex/lexicon.ex b/lib/atex/lexicon.ex index 3b52e78..6da755e 100644 --- a/lib/atex/lexicon.ex +++ b/lib/atex/lexicon.ex @@ -240,7 +240,7 @@ defmodule Atex.Lexicon do defstruct unquote(struct_keys) def from_json(json) do - case apply(unquote(schema_module), unquote(atomise(def_name)), [json]) do + case apply(unquote(schema_module), unquote(atomise(Recase.to_snake(def_name))), [json]) do {:ok, map} -> {:ok, struct(__MODULE__, map)} err -> err end @@ -440,6 +440,47 @@ defmodule Atex.Lexicon do ] end + defp def_to_schema(nsid, def_name, %{type: "ref", ref: ref}) do + target_module = + nsid + |> Atex.NSID.expand_possible_fragment_shorthand(ref) + |> ref_to_module() + + {quoted_schema, quoted_type} = field_to_schema(%{type: "ref", ref: ref}, nsid) + + quoted_struct = + quote do + def from_json(json), do: unquote(target_module).from_json(json) + end + + [{atomise(def_name), quoted_schema, quoted_type, quoted_struct}] + end + + defp def_to_schema(nsid, def_name, %{type: "union", refs: refs}) do + target_modules = + Enum.map(refs, fn ref -> + nsid + |> Atex.NSID.expand_possible_fragment_shorthand(ref) + |> ref_to_module() + end) + + {quoted_schema, quoted_type} = field_to_schema(%{type: "union", refs: refs}, nsid) + + quoted_struct = + quote do + def from_json(json) do + Enum.find_value(unquote(target_modules), {:error, :no_matching_type}, fn mod -> + case mod.from_json(json) do + {:ok, _} = ok -> ok + _ -> nil + end + end) + end + end + + [{atomise(def_name), quoted_schema, quoted_type, quoted_struct}] + end + defp def_to_schema(nsid, def_name, %{type: type} = def) when type in [ "blob", @@ -449,9 +490,7 @@ defmodule Atex.Lexicon do "string", "bytes", "cid-link", - "unknown", - "ref", - "union" + "unknown" ] do {quoted_schema, quoted_type} = field_to_schema(def, nsid) [{atomise(def_name), quoted_schema, quoted_type}] @@ -658,6 +697,20 @@ defmodule Atex.Lexicon do defp atomise(x) when is_atom(x), do: x defp atomise(x) when is_binary(x), do: String.to_atom(x) + # Resolves a fully-expanded NSID (possibly with a `#fragment`) to the + # Elixir module atom that `deflexicon` generates for it. When the fragment is + # `main` (or absent), the module is the root NSID module. Otherwise it is a + # PascalCase-named submodule of the root NSID module. + defp ref_to_module(expanded_nsid) do + {nsid_atom, fragment} = Atex.NSID.to_atom_with_fragment(expanded_nsid) + + if fragment == :main do + nsid_atom + else + Module.concat(nsid_atom, Recase.to_pascal(to_string(fragment))) + end + end + defp join_with_pipe(list) when is_list(list) do [piped] = do_join_with_pipe(list) piped diff --git a/mix.exs b/mix.exs index 035ae51..20c788b 100644 --- a/mix.exs +++ b/mix.exs @@ -10,6 +10,7 @@ defmodule Atex.MixProject do app: :atex, version: @version, elixir: "~> 1.18", + elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), name: "atex", @@ -19,6 +20,9 @@ defmodule Atex.MixProject do ] end + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + def application do [ extra_applications: [:logger], diff --git a/test/atex/lexicon_test.exs b/test/atex/lexicon_test.exs new file mode 100644 index 0000000..d1fb657 --- /dev/null +++ b/test/atex/lexicon_test.exs @@ -0,0 +1,138 @@ +defmodule Atex.LexiconTest do + use ExUnit.Case, async: true + + # Fixture modules are defined in test/support/lexicon_fixtures.ex and + # compiled before tests run via the :test elixirc_paths config in mix.exs. + + # --------------------------------------------------------------------------- + # Tests: ref-typed procedure input (local ref) + # --------------------------------------------------------------------------- + + describe "procedure with local ref-typed input" do + test "generates an Input submodule" do + assert Code.ensure_loaded?(Lexicon.Test.CreatePost.Input) + end + + test "Input submodule exports from_json/1" do + assert function_exported?(Lexicon.Test.CreatePost.Input, :from_json, 1) + end + + test "from_json/1 succeeds for valid data" do + assert {:ok, result} = Lexicon.Test.CreatePost.Input.from_json(%{"text" => "hello"}) + assert result.text == "hello" + end + + test "from_json/1 returns error for invalid data" do + assert {:error, _} = Lexicon.Test.CreatePost.Input.from_json(%{}) + end + end + + # --------------------------------------------------------------------------- + # Tests: ref-typed query output (local ref) + # --------------------------------------------------------------------------- + + describe "query with local ref-typed output" do + test "generates an Output submodule" do + assert Code.ensure_loaded?(Lexicon.Test.GetPost.Output) + end + + test "Output submodule exports from_json/1" do + assert function_exported?(Lexicon.Test.GetPost.Output, :from_json, 1) + end + + test "from_json/1 succeeds for valid data" do + assert {:ok, result} = + Lexicon.Test.GetPost.Output.from_json(%{ + "uri" => "at://did:plc:abc/app.bsky.feed.post/123" + }) + + assert result.uri == "at://did:plc:abc/app.bsky.feed.post/123" + end + + test "from_json/1 returns error for invalid data" do + assert {:error, _} = Lexicon.Test.GetPost.Output.from_json(%{}) + end + end + + # --------------------------------------------------------------------------- + # Tests: ref-typed procedure input (cross-NSID ref targeting a `main` def) + # --------------------------------------------------------------------------- + + describe "procedure with cross-NSID ref-typed input" do + test "generates an Input submodule" do + assert Code.ensure_loaded?(Lexicon.Test.CreateProfile.Input) + end + + test "Input submodule exports from_json/1" do + assert function_exported?(Lexicon.Test.CreateProfile.Input, :from_json, 1) + end + + test "from_json/1 delegates to the referenced module" do + assert {:ok, result} = + Lexicon.Test.CreateProfile.Input.from_json(%{"did" => "did:plc:abc"}) + + assert result.did == "did:plc:abc" + end + + test "from_json/1 returns error when referenced module rejects data" do + assert {:error, _} = Lexicon.Test.CreateProfile.Input.from_json(%{}) + end + end + + # --------------------------------------------------------------------------- + # Tests: union-typed procedure input (local refs) + # --------------------------------------------------------------------------- + + describe "procedure with union-typed input" do + test "generates an Input submodule" do + assert Code.ensure_loaded?(Lexicon.Test.CreateUnion.Input) + end + + test "Input submodule exports from_json/1" do + assert function_exported?(Lexicon.Test.CreateUnion.Input, :from_json, 1) + end + + test "from_json/1 succeeds for the first union member" do + assert {:ok, result} = Lexicon.Test.CreateUnion.Input.from_json(%{"text" => "hello"}) + assert result.text == "hello" + end + + test "from_json/1 succeeds for the second union member" do + assert {:ok, result} = Lexicon.Test.CreateUnion.Input.from_json(%{"error" => "bad"}) + assert result.error == "bad" + end + + test "from_json/1 returns :no_matching_type when no member matches" do + assert {:error, :no_matching_type} = + Lexicon.Test.CreateUnion.Input.from_json(%{"unknown" => "field"}) + end + end + + # --------------------------------------------------------------------------- + # Tests: union-typed query output (cross-NSID refs) + # --------------------------------------------------------------------------- + + describe "query with union-typed output (cross-NSID)" do + test "generates an Output submodule" do + assert Code.ensure_loaded?(Lexicon.Test.GetUnion.Output) + end + + test "Output submodule exports from_json/1" do + assert function_exported?(Lexicon.Test.GetUnion.Output, :from_json, 1) + end + + test "from_json/1 succeeds for the first union member" do + assert {:ok, result} = Lexicon.Test.GetUnion.Output.from_json(%{"did" => "did:plc:abc"}) + assert result.did == "did:plc:abc" + end + + test "from_json/1 succeeds for the second union member" do + assert {:ok, result} = Lexicon.Test.GetUnion.Output.from_json(%{"error" => "oops"}) + assert result.error == "oops" + end + + test "from_json/1 returns :no_matching_type when no member matches" do + assert {:error, :no_matching_type} = Lexicon.Test.GetUnion.Output.from_json(%{}) + end + end +end diff --git a/test/support/lexicon_fixtures.ex b/test/support/lexicon_fixtures.ex new file mode 100644 index 0000000..7d14f67 --- /dev/null +++ b/test/support/lexicon_fixtures.ex @@ -0,0 +1,197 @@ +# Lexicon fixture modules for Atex.LexiconTest. +# +# Defined in test/support so they are compiled before tests run, ensuring +# Code.ensure_loaded? and function_exported? return correct results even when +# Atex.LexiconTest runs with async: true. + +# Standalone record used as the target of cross-NSID ref/union tests. +# NSID "lexicon.test.profileView" -> Lexicon.Test.ProfileView +defmodule Lexicon.Test.ProfileView do + @moduledoc false + use Atex.Lexicon + + deflexicon(%{ + "lexicon" => 1, + "id" => "lexicon.test.profileView", + "defs" => %{ + "main" => %{ + "type" => "object", + "required" => ["did"], + "properties" => %{ + "did" => %{"type" => "string"} + } + } + } + }) +end + +# Standalone record used as the second member of cross-NSID union tests. +# NSID "lexicon.test.errorView" -> Lexicon.Test.ErrorView +defmodule Lexicon.Test.ErrorView do + @moduledoc false + use Atex.Lexicon + + deflexicon(%{ + "lexicon" => 1, + "id" => "lexicon.test.errorView", + "defs" => %{ + "main" => %{ + "type" => "object", + "required" => ["error"], + "properties" => %{ + "error" => %{"type" => "string"} + } + } + } + }) +end + +# Procedure whose input.schema is a local `ref` to a sibling def. +# NSID "lexicon.test.createPost" -> Lexicon.Test.CreatePost +defmodule Lexicon.Test.CreatePost do + @moduledoc false + use Atex.Lexicon + + deflexicon(%{ + "lexicon" => 1, + "id" => "lexicon.test.createPost", + "defs" => %{ + "main" => %{ + "type" => "procedure", + "input" => %{ + "encoding" => "application/json", + "schema" => %{"type" => "ref", "ref" => "#postInput"} + }, + "output" => %{ + "encoding" => "application/json", + "schema" => %{ + "type" => "object", + "required" => ["uri"], + "properties" => %{"uri" => %{"type" => "string"}} + } + } + }, + "postInput" => %{ + "type" => "object", + "required" => ["text"], + "properties" => %{ + "text" => %{"type" => "string"} + } + } + } + }) +end + +# Query whose output.schema is a local `ref` to a sibling def. +# NSID "lexicon.test.getPost" -> Lexicon.Test.GetPost +defmodule Lexicon.Test.GetPost do + @moduledoc false + use Atex.Lexicon + + deflexicon(%{ + "lexicon" => 1, + "id" => "lexicon.test.getPost", + "defs" => %{ + "main" => %{ + "type" => "query", + "output" => %{ + "encoding" => "application/json", + "schema" => %{"type" => "ref", "ref" => "#postView"} + } + }, + "postView" => %{ + "type" => "object", + "required" => ["uri"], + "properties" => %{ + "uri" => %{"type" => "string"} + } + } + } + }) +end + +# Procedure whose input.schema is a cross-NSID `ref` targeting a `main` def. +# NSID "lexicon.test.createProfile" -> Lexicon.Test.CreateProfile +defmodule Lexicon.Test.CreateProfile do + @moduledoc false + use Atex.Lexicon + + deflexicon(%{ + "lexicon" => 1, + "id" => "lexicon.test.createProfile", + "defs" => %{ + "main" => %{ + "type" => "procedure", + "input" => %{ + "encoding" => "application/json", + "schema" => %{"type" => "ref", "ref" => "lexicon.test.profileView"} + } + } + } + }) +end + +# Procedure whose input.schema is a `union` of two local refs. +# NSID "lexicon.test.createUnion" -> Lexicon.Test.CreateUnion +defmodule Lexicon.Test.CreateUnion do + @moduledoc false + use Atex.Lexicon + + deflexicon(%{ + "lexicon" => 1, + "id" => "lexicon.test.createUnion", + "defs" => %{ + "main" => %{ + "type" => "procedure", + "input" => %{ + "encoding" => "application/json", + "schema" => %{ + "type" => "union", + "refs" => ["#postInput", "#errorInput"] + } + } + }, + "postInput" => %{ + "type" => "object", + "required" => ["text"], + "properties" => %{ + "text" => %{"type" => "string"} + } + }, + "errorInput" => %{ + "type" => "object", + "required" => ["error"], + "properties" => %{ + "error" => %{"type" => "string"} + } + } + } + }) +end + +# Query whose output.schema is a `union` of two cross-NSID refs. +# NSID "lexicon.test.getUnion" -> Lexicon.Test.GetUnion +defmodule Lexicon.Test.GetUnion do + @moduledoc false + use Atex.Lexicon + + deflexicon(%{ + "lexicon" => 1, + "id" => "lexicon.test.getUnion", + "defs" => %{ + "main" => %{ + "type" => "query", + "output" => %{ + "encoding" => "application/json", + "schema" => %{ + "type" => "union", + "refs" => [ + "lexicon.test.profileView", + "lexicon.test.errorView" + ] + } + } + } + } + }) +end -- 2.51.2