In this post I will explain how to create a type system directive in Absinthe to implement Input Unions in Graphql. The code for this post can be found on Github
A common issue with GraphQL is the lack of input unions. This means an input object that lets you choose between a number of potential input types.
For example (taken from a spec discussion):
input PetInput {
cat: CatInput
dog: DogInput
fish: FishInput
}
input CatInput {
name: String!
numberOfLives: Int
}
input DogInput {
name: String!
wagsTail: Boolean
}
input FishInput {
name: String!
bodyLengthInMm: Int
}
type Mutation {
addPet(pet: PetInput!): Pet
}
The PetInput input object should have exactly one field with a value, all others being null.
There’s been a lot of discussion over this issue in the GraphQL spec. See this gist for a list of all variants. One of the solutions “OneOf Input Objects” is now in the RFC 1 stage, and the good news is that it can be implemented without any changes in syntax, namely with type system directives.
Type system directives
With the release of Absinthe 1.7, support was added for type system directives in the macro notation. These are directives that can be placed on your GraphQL schema. For example, a built-in type system directive is @deprecated
. It can be applied to fields to mark them as deprecated.
This is in contrast to operation directives, as these are applied to GraphQL documents. For example, @include
and @skip
are built-in operation directives .
We will now implement the @oneOf
type system directive. We’ll start with an Absinthe schema definition.
defmodule AbsintheOneOf.Schema do
use Absinthe.Schema
query do
end
import_sdl("""
input PetInput @oneOf {
cat: CatInput
dog: DogInput
fish: FishInput
}
input CatInput { name: String!, numberOfLives: Int }
input DogInput { name: String!, wagsTail: Boolean }
input FishInput { name: String!, bodyLengthInMm: Int }
type Mutation {
addPet(pet: PetInput!): Pet
}
union Pet = Cat | Dog | Fish
type Cat {
name: String!
numberOfLives: Int
}
type Dog {
name: String!
wagsTail: Boolean
}
type Fish {
name: String!
bodyLengthInMm: Int
}
""")
# the resolver functions can be found in the repository.
end
This schema uses the SDL notation to build. The same can be accomplished using the macro notation but for brevity and clarity this is used. Setting a directive in the macro notation is as simple as:
input_object :pet_input do
directive :one_of
field :cat, :cat_input
field :dog, :dog_input
field :fish, :fish_input
end
When you would compile this in an Elixir project an error would be raised since the @oneOf
directive in the first line does not exist. This leads to a nifty bit in Absinthe, to compile the schema you need a certain directive but to create the directive you need a schema. This is why Absinthe has prototype schemas. They can expose the directives needed for the actual schema. In the Absinthe codebase you can see for example the @deprecated
directive defined in a prototype schema.
The prototype schema for the @oneOf
directive looks like this:
defmodule AbsintheOneOf.OneOfDirective do
use Absinthe.Schema.Prototype
directive :one_of do
on([:input_object])
expand(fn
_args, node ->
%{node | __private__: Keyword.put(node.__private__, :one_of, true)}
end)
end
end
Two things to pay attention to, a directive has an on
field declaring to which types it can be applied. Secondly, the expand function. It accepts arguments, in this case there are none but these are the arguments passed into the directive. The second argument is the schema node. We mark this node’s __private__
field with one_of: true
. At a later stage we can use this to validate whether incoming documents use the input union correctly.
To use this prototype schema it needs to be imported in the proper schema, like so:
defmodule AbsintheOneOf.Schema do
use Absinthe.Schema
@prototype_schema AbsintheOneOf.OneOfDirective
# ...
Now the schema should compile.
The next step is adding a document phase to validate incoming documents with the @oneOf
directive. When multiple input fields of input object are not null an error should be added. I’ve written about phases and using them before so I won’t go into that in depth.
defmodule AbsintheOneOf.Phase do
@behaviour Absinthe.Phase
alias Absinthe.Blueprint
def run(blueprint, _config) do
result = Blueprint.prewalk(blueprint, &handle_node/1)
{:ok, result}
end
defp handle_node(
%Absinthe.Blueprint.Input.Argument{
input_value: %Blueprint.Input.Value{
normalized: %Absinthe.Blueprint.Input.Object{schema_node: schema_node} = input_object
}
} = node
) do
schema_node = Absinthe.Type.unwrap(schema_node)
if Keyword.get(schema_node.__private__, :one_of) == true do
count = Enum.count(input_object.fields)
if count != 1 do
Absinthe.Phase.put_error(node, error(node, count))
else
node
end
else
node
end
end
defp handle_node(node) do
node
end
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}.",
locations: [node.source_location]
}
end
end
In short, the phase checks whether input objects with the one_of: true
flag set, have more than one input field. If so, an error is placed in the document.
This phase can be added after the other validation phases.
pipeline =
schema
|> Absinthe.Pipeline.for_document(options)
|> Absinthe.Pipeline.insert_after(
Absinthe.Phase.Document.Validation.OnlyOneSubscription,
AbsintheOneOf.Phase
)
This is it. Now when you run the mutation:
mutation addPet($pet: PetInput!) {
addPet(pet: $pet) {
... on Cat {
name
numberOfLives
}
... on Fish {
name
}
... on Dog {
name
}
__typename
}
}
with the following data:
%{
"pet" => %{
"cat" => %{
"name" => "Garfield",
"numberOfLives" => 9
},
"dog" => %{
"name" => "Odie",
"wagsTail" => true
}
}
}
an error will be returned OneOf Object \"pet\" must have exactly one non-null field but got 2.
.
With the release of Absinthe 1.7.1 it’ll also be possible to import directives, in the same way as you can import types. This will make it even easier to use type system directives in your schema’s.
I’ve shown how to use type system directives. They can be extremely powerful tool to make your GraphQL schema more composable. In this case we created the @oneOf
directive to build a new feature into GraphQL without any syntax changes. It also showcases how flexible Absinthe is. All this functionality was possible without any changes to Absinthe itself.
Photo by Todd Mittens on Unsplash