Defining GraphQL type APIs

First some imports:

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}

module Introduction where

import Protolude

import System.Random

import GraphQL
import GraphQL.API (Object, Field, Argument, (:>), Union)
import GraphQL.Resolver (Handler, (:<>)(..), unionValue)

A simple GraphQL service

A GraphQL service is made up of two things:

  1. A schema that defines the service
  2. Some code that implements the service’s behavior

We’re going to build a very simple service that says hello to people. Our GraphQL schema for this looks like:

type Hello {
  greeting(who: String!): String!
}

Which means we have base type, an object called Hello, which has a single field greeting, which takes a non-nullable String called who and returns a String.

Note that all the types here are GraphQL types, not Haskell types. String here is a GraphQL String, not a Haskell one.

And we want to be able to send queries that look like:

{
  greeting(who: "world")
}

And get responses like:

{
  "data": {
    "greeting": "Hello world!"
  }
}

Defining the schema

Here’s how we would define the schema in Haskell:

type Hello = Object "Hello" '[]
  '[ Argument "who" Text :> Field "greeting" Text
   ]

Breaking this down, we define a new Haskell type Hello, which is a GraphQL object (also named "Hello") that implements no interfaces (hence '[]). It has one field, called "greeting" which returns some Text and takes a single named argument "who", which is also Text.

Note that the GraphQL String from above got translated into a Haskell Text.

There are some noteworthy differences between this schema and the GraphQL schema:

  • The GraphQL schema requires a special annotation to say that a value cannot be null, !. In Haskell, we instead assume that nothing can be null.
  • In the GraphQL schema, the argument appears after the field name. In Haskell, it appears before.
  • In Haskell, we name the top-level type twice, once on left hand side of the type definition and once on the right.

Implementing the handlers

Once we have the schema, we need to define the corresponding handlers, which are Handler values.

Here’s a Handler for Hello:

hello :: Handler IO Hello
hello = pure greeting
  where
    greeting who = pure ("Hello " <> who <> "!")

The type signature, Handler IO Hello shows that it’s a Handler for Hello, and that it runs in the IO monad. (Note: nothing about this example code requires the IO monad, it’s just a monad that lots of people has heard of.)

The implementation looks slightly weird, but it’s weird for good reasons.

The first layer of the handler, pure greeting, produces the Hello object. The pure might seem redundant here, but making this step monadic allows us to run actions in the base monad.

The second layer of the handler, the implementation of greeting, produces the value of the greeting field. It is monadic so that it will only be executed when the field was requested.

Each field handler is a separate monadic action so we only perform the side effects for fields present in the query.

This handler is in Identity because it doesn’t do anything particularly monadic. It could be in IO or STM or ExceptT Text IO or whatever you would like.

Running queries

Defining a service isn’t much point unless you can query. Here’s how:

queryHello :: IO Response
queryHello = interpretAnonymousQuery @Hello hello "{ greeting(who: \"mort\") }"

The actual Response type is fairly verbose, so we’re most likely to turn it into JSON:

λ Aeson.encode <$> queryHello
"{\"greeting\":\"Hello mort!\"}"

Combining field handlers with :<>

How do we define an object with more than one field?

Let’s implement a simple calculator that can add and subtract integers. First, the schema:

type Calculator {
  add(a: Int!, b: Int!): Int!,
  sub(a: Int!, b: Int!): Int!,
}

Here, Calculator is an object with two fields: add and sub.

And now the Haskell version:

type Calculator = Object "Calculator" '[]
  '[ Argument "a" Int32 :> Argument "b" Int32 :> Field "add" Int32
   , Argument "a" Int32 :> Argument "b" Int32 :> Field "subtract" Int32
   ]

So far, this is the same as our Hello example.

And its handler:

calculator :: Handler IO Calculator
calculator = pure (add :<> subtract')
  where
    add a b = pure (a + b)
    subtract' a b = pure (a - b)

This handler introduces a new operator, :<> (pronounced “birdface”), which is used to compose two existing handlers into a new handler. It’s inspired by the operator for monoids, <>.

Note that we still need pure for each individual handler.

Nesting Objects

How do we define objects made up other objects?

One of the great things in GraphQL is that objects can be used as types for fields. Take this classic GraphQL schema as an example:

type Query {
  me: User!
}

type User {
  name: Text!
}

We would query this schema with something like:

{
  me {
    name
  }
}

Which would produce output like:

{
  "data": {
    "me": {
      "name": "Mort"
    }
  }
}

The Haskell type for this schema looks like:

type User = Object "User" '[] '[Field "name" Text]
type Query = Object "Query" '[] '[Field "me" User]

Note that Query refers to the type User when it defines the field me.

We write nested handlers the same way we write the top-level handler:

user :: Handler IO User
user = pure name
  where
    name = pure "Mort"

query :: Handler IO Query
query = pure user

And that’s it.

Unions

GraphQL has support for union types. These require special treatment in Haskell.

Let’s define a union, first in GraphQL:

union UserOrCalculator = User | Calculator

And now in Haskell:

type UserOrCalculator = Union "UserOrCalculator" '[User, Calculator]

And let’s define a very simple top-level object that uses UserOrCalculator:

type UnionQuery = Object "UnionQuery" '[] '[Field "union" UserOrCalculator]

and a handler that randomly returns either a user or a calculator:

unionQuery :: Handler IO UnionQuery
unionQuery = do
  returnUser <- randomIO
  if returnUser
  then pure (unionValue @User user)
  else pure (unionValue @Calculator calculator)

The important thing here is that we have to wrap the actual objects we return using unionValue.

Note that while unionValue looks a bit like unsafeCoerce by forcing one type to become another type, it’s actually type-safe because we use a type-index to pick the correct type from the union. Using e.g. unionValue @HelloWorld handler will not compile because HelloWorld is not in the union.

Where next?

We have an examples directory showing full code examples.

We also have a fair number of end-to-end tests based on an example schema that you might find interesting.

If you want to try the examples in this tutorial you can run:

stack repl tutorial