Skip to content

Commit

Permalink
support object nested one_of
Browse files Browse the repository at this point in the history
  • Loading branch information
rudebono committed May 14, 2024
1 parent 587da05 commit a3fa49a
Show file tree
Hide file tree
Showing 5 changed files with 360 additions and 1 deletion.
16 changes: 16 additions & 0 deletions lib/directive.ex
Original file line number Diff line number Diff line change
@@ -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
80 changes: 80 additions & 0 deletions lib/phase.ex
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -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"},
}
248 changes: 248 additions & 0 deletions test/absinthe_one_of_test.exs
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit a3fa49a

Please sign in to comment.