diff --git a/lib/directive.ex b/lib/directive.ex new file mode 100644 index 0000000..f119c15 --- /dev/null +++ b/lib/directive.ex @@ -0,0 +1,16 @@ +defmodule AbsintheOneOf.Directive do + use Absinthe.Schema.Prototype + + directive :one_of do + on([:input_object]) + expand(&expand_one_of/2) + end + + @spec expand_one_of( + arguments :: %{}, + node :: Absinthe.Blueprint.Schema.InputObjectTypeDefinition.t() + ) :: Absinthe.Blueprint.Schema.InputObjectTypeDefinition.t() + defp expand_one_of(_arguments, %Absinthe.Blueprint.Schema.InputObjectTypeDefinition{} = node) do + %{node | __private__: Keyword.put(node.__private__, :one_of, true)} + end +end diff --git a/lib/phase.ex b/lib/phase.ex new file mode 100644 index 0000000..9e184ba --- /dev/null +++ b/lib/phase.ex @@ -0,0 +1,80 @@ +defmodule AbsintheOneOf.Phase do + @behaviour Absinthe.Phase + + @impl true + @spec run( + blueprint :: Absinthe.Blueprint.node_t(), + config :: any() + ) :: {:ok, Absinthe.Blueprint.node_t()} + def run(blueprint, _config) do + {:ok, Absinthe.Blueprint.prewalk(blueprint, &prewalk/1)} + end + + @spec prewalk(node :: Absinthe.Blueprint.node_t()) :: Absinthe.Blueprint.node_t() + defp prewalk(%Absinthe.Blueprint.Input.Argument{} = node) do + case find_invalid(node) do + {nil, 0} -> + node + + {invalid_node, count} -> + Absinthe.Phase.put_error(node, error(invalid_node, count)) + end + end + + defp prewalk(node), do: node + + @spec find_invalid(node :: Absinthe.Blueprint.node_t()) :: + {nil | Absinthe.Blueprint.node_t(), integer()} + defp find_invalid( + %{ + input_value: %Absinthe.Blueprint.Input.Value{ + normalized: %Absinthe.Blueprint.Input.Object{fields: fields} + } + } = node + ) do + Enum.reduce_while( + fields, + if valid?(node) do + {nil, 0} + else + {node, Enum.count(fields)} + end, + fn field, {invalid_node, count} -> + if invalid_node do + {:halt, {invalid_node, count}} + else + {:cont, find_invalid(field)} + end + end + ) + end + + defp find_invalid(_node), do: {nil, 0} + + @spec valid?(node :: Absinthe.Blueprint.node_t()) :: boolean() + defp valid?( + %{ + input_value: %Absinthe.Blueprint.Input.Value{ + normalized: %Absinthe.Blueprint.Input.Object{ + schema_node: %{} = schema_node, + fields: fields + } + } + } = _node + ) do + schema_node = Absinthe.Type.unwrap(schema_node) + private = Map.get(schema_node, :__private__, []) + !(Keyword.get(private, :one_of, false) && Enum.count(fields) != 1) + end + + defp valid?(_node), do: true + + @spec error(node :: Absinthe.Blueprint.node_t(), count :: integer()) :: Absinthe.Phase.Error.t() + defp error(node, count) do + %Absinthe.Phase.Error{ + phase: __MODULE__, + message: + "OneOf Object \"#{node.name}\" must have exactly one non-null field but got #{count}." + } + end +end diff --git a/mix.exs b/mix.exs index 3eabc8c..31f5c77 100644 --- a/mix.exs +++ b/mix.exs @@ -19,7 +19,9 @@ defmodule AbsintheOneOf.MixProject do defp deps() do [ - {:absinthe, "~> 1.7"} + {:absinthe, "~> 1.7"}, + {:absinthe_phoenix, "~> 2.0", only: :test}, + {:jason, "~> 1.4", only: :test} ] end end diff --git a/mix.lock b/mix.lock index 8b29114..9f00fb1 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,18 @@ %{ "absinthe": {:hex, :absinthe, "1.7.6", "0b897365f98d068cfcb4533c0200a8e58825a4aeeae6ec33633ebed6de11773b", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7626951ca5eec627da960615b51009f3a774765406ff02722b1d818f17e5778"}, + "absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.3", "74e0862f280424b7bc290f6f69e133268bce0b4e7db0218c7e129c5c2b1d3fd4", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.5", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "caffaea03c17ea7419fe07e4bc04c2399c47f0d8736900623dbf4749a826fd2c"}, + "absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"}, + "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"}, + "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, + "phoenix": {:hex, :phoenix, "1.7.12", "1cc589e0eab99f593a8aa38ec45f15d25297dd6187ee801c8de8947090b5a9d3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d646192fbade9f485b01bc9920c139bfdd19d0f8df3d73fd8eaf2dfbe0d2837c"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.6", "0437fe56e093fd4ac422de33bf8fc89f7bc1416a3f2d732d8b2c8fd54792fe60", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "e04378d26b0af627817ae84c92083b7e97aca3121196679b73c73b99d0d133ea"}, } diff --git a/test/absinthe_one_of_test.exs b/test/absinthe_one_of_test.exs new file mode 100644 index 0000000..ebe0758 --- /dev/null +++ b/test/absinthe_one_of_test.exs @@ -0,0 +1,248 @@ +defmodule AbsintheOneOfTest do + use ExUnit.Case, async: true + use Plug.Test + + import Phoenix.ConnTest, only: [json_response: 2] + + defmodule Pet do + defmodule Cat do + defstruct [:name, :number_of_lives] + end + + defmodule Dog do + defstruct [:name, :wags_tail] + end + + defmodule Fish do + defstruct [:name, :body_length_in_mm] + end + + def pets(_, _, _) do + {:ok, []} + end + + def add(_, %{pet: %{cat: %{name: name, number_of_lives: number_of_lives}}}, _) do + {:ok, %Cat{name: name, number_of_lives: number_of_lives}} + end + + def add(_, %{pet: %{dog: %{name: name, wags_tail: wags_tail}}}, _) do + {:ok, %Dog{name: name, wags_tail: wags_tail}} + end + + def add(_, %{pet: %{fish: %{name: name, body_length_in_mm: body_length_in_mm}}}, _) do + {:ok, %Fish{name: name, body_length_in_mm: body_length_in_mm}} + end + end + + defmodule TestSchema do + use Absinthe.Schema + + @prototype_schema AbsintheOneOf.Directive + + query do + field(:pets, list_of(:pet)) do + resolve(&AbsintheOneOfTest.Pet.pets/3) + end + end + + mutation do + field(:add, :pet) do + arg(:pet, non_null(:pet_input)) + resolve(&AbsintheOneOfTest.Pet.add/3) + end + end + + union(:pet) do + types([:cat, :dog, :fish]) + + resolve_type(fn + %AbsintheOneOfTest.Pet.Cat{}, _ -> :cat + %AbsintheOneOfTest.Pet.Dog{}, _ -> :dog + %AbsintheOneOfTest.Pet.Fish{}, _ -> :fish + end) + end + + object(:cat) do + field(:name, non_null(:string)) + field(:number_of_lives, :integer) + end + + object(:dog) do + field(:name, non_null(:string)) + field(:wags_tail, :boolean) + end + + object(:fish) do + field(:name, non_null(:string)) + field(:body_length_in_mm, :integer) + end + + input_object(:pet_input) do + directive(:one_of) + field(:cat, :cat_input) + field(:dog, :dog_input) + field(:fish, :fish_input) + end + + input_object(:cat_input) do + field(:name, non_null(:string)) + field(:number_of_lives, :integer) + end + + input_object(:dog_input) do + field(:name, non_null(:string)) + field(:wags_tail, :boolean) + end + + input_object(:fish_input) do + field(:name, non_null(:string)) + field(:body_length_in_mm, :integer) + end + end + + def pipeline(config, pipeline_opts) do + config.schema_mod + |> Absinthe.Pipeline.for_document(pipeline_opts) + |> Absinthe.Pipeline.insert_after( + Absinthe.Phase.Document.Validation.OnlyOneSubscription, + AbsintheOneOf.Phase + ) + end + + def call(conn) do + conn + |> Plug.Parsers.call( + Plug.Parsers.init( + parsers: [ + :urlencoded, + :multipart, + :json, + Absinthe.Plug.Parser + ], + json_decoder: Jason + ) + ) + |> Absinthe.Plug.call( + Absinthe.Plug.init( + schema: TestSchema, + pipeline: {__MODULE__, :pipeline} + ) + ) + end + + @mutation """ + mutation add($pet: PetInput!) { + add(pet: $pet) { + ... on Cat { + name + numberOfLives + } + ... on Dog { + name + wagsTail + } + ... on Fish { + name + bodyLengthInMm + } + __typename + } + } + """ + + test "one_of 0 input" do + variables = %{ + "pet" => %{} + } + + assert %{ + "errors" => [ + %{ + "message" => + "OneOf Object \"pet\" must have exactly one non-null field but got 0." + } + ] + } == + conn(:post, "/", %{query: @mutation, variables: variables}) + |> call() + |> json_response(200) + end + + test "one_of 1 input" do + variables = %{ + "pet" => %{ + "cat" => %{ + "name" => "Garfield", + "numberOfLives" => 9 + } + } + } + + assert %{ + "data" => %{ + "add" => %{"__typename" => "Cat", "name" => "Garfield", "numberOfLives" => 9} + } + } == + conn(:post, "/", %{query: @mutation, variables: variables}) + |> call() + |> json_response(200) + end + + test "one_of 2 input" do + variables = %{ + "pet" => %{ + "cat" => %{ + "name" => "Garfield", + "numberOfLives" => 9 + }, + "dog" => %{ + "name" => "Odie", + "wagsTail" => true + } + } + } + + assert %{ + "errors" => [ + %{ + "message" => + "OneOf Object \"pet\" must have exactly one non-null field but got 2." + } + ] + } == + conn(:post, "/", %{query: @mutation, variables: variables}) + |> call() + |> json_response(200) + end + + test "one_of 3 input" do + variables = %{ + "pet" => %{ + "cat" => %{ + "name" => "Garfield", + "numberOfLives" => 9 + }, + "dog" => %{ + "name" => "Odie", + "wagsTail" => true + }, + "fish" => %{ + "name" => "Nemo", + "bodyLengthInMm" => 100 + } + } + } + + assert %{ + "errors" => [ + %{ + "message" => + "OneOf Object \"pet\" must have exactly one non-null field but got 3." + } + ] + } == + conn(:post, "/", %{query: @mutation, variables: variables}) + |> call() + |> json_response(200) + end +end