Creating an Input Union Type System Directive in Absinthe

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