[281] GraphQL with Absinthe

An introduction to GraphQL, using the Absinthe package to create a GraphQL server.

Subscribe now

GraphQL with Absinthe [12.05.2016]

GraphQL describes itself as A Query Language for your API. Absinthe is an Elixir package to make it easy to produce GraphQL endpoints.

Today we'll look at the basics of GraphQL and create a basic GraphQL server. Let's get started.

Project

First off, let's have a look at GraphQL itself. This is GraphQL.org. You can see that you describe your data with types: so you say what type of data you have. You do this for all of your types. Then you can ask for what you want: so here you can say we want to get a project named GraphQL, and you'd like it's tagline. Then, you get back exactly what you asked for and nothing else.

There are a lot more details on the website.

Next, we'll look at graphiql, which is an in-browser tool for exploring a GraphQL endpoint.

NOTE: I was informed that absinthe_plug actually ships an enhanced / built-in GraphiQL. This is really cool!

Here we're looking at the HackerNews API by way of GraphQL Hub. We can click the Play button to see the query execute and see the top post on HackerNews right now. It's easy to look at the query and figure out how to tweak it - for instance, to see the top 5 posts instead.

The most interesting bit here is the API introspection. Here we'll look at the hackernews API, and we can see all of the items available under this object. One of them is topStories, which is what we're using. It has two arguments: limit and offset. We can see that the introspection also works inside of the query builder, and we can see that this is the initial offset of the number of items to return. Let's look at page 2.

We can also see that it returns a list of HackerNewsItems. If we click we can see what they consist of. Let's add the score.

There's obviously a lot more to learn about GraphQL if you're interested, but this isn't the time or place. There are a host of links in the resources section if you'd like to check there.

Next, we'll start playing with Absinthe. Absinthe is a GraphQL server-builder for Elixir. We have an existing app, the backend I use for the time-tracker elm and phoenix project. We'll begin with that repo, tagged with before_episode_281.

Adding Absinthe to an existing Phoenix application

Let's begin by pulling in the absinthe dependency:

cd time_tracker_backend
vim mix.exs
defmodule TimeTrackerBackend.Mixfile do
  # ...
  def application do
    [ # ...
      applications: [
        # ...
        :absinthe,
      ]
    ]
  end
  defp deps do
    [ # ...
      {:absinthe, "~> 1.2.0"},
    ]
  end
  # ...
end
mix deps.get

Creating our first schema

First, a type

Next, we'll begin creating our schema. The schema describes our data layer as a graph of related, typed objects. Initially, we'll just present a list of our users. We'll need types to start out, so let's make a Types file and specify our user objects' type:

mkdir web/schema
vim web/schema/types.ex

We'll start out with a basic shell in which we'll fill out our user's type definition.

defmodule TimeTrackerBackend.Schema.Types do
  use Absinthe.Schema.Notation

  object :user do
    # stuff goes here!
  end
end

We can mostly copy and paste our Ecto schema for our user in here for now, eliding the bits we don't want to expose:

defmodule TimeTrackerBackend.Schema.Types do
  use Absinthe.Schema.Notation

  object :user do
    field :id, :id
    field :name, :string
    field :gender, :string
    field :email, :string
    field :username, :string
    field :is_active, :boolean
    field :avatar, :string
  end
end

On to the schema

Now that we have a type for our user, we can create the beginnings of our schema:

vim web/schema.ex
defmodule TimeTrackerBackend.Schema do
  use Absinthe.Schema
  # The import_Types macro brings our types in to use
  import_types TimeTrackerBackend.Schema.Types

  # We'll define a query to get our users.  It returns a list of users, and this
  # query is resolved by a function in a module we'll make momentarily.
  query do
    field :users, list_of(:user) do
      resolve &TimeTrackerBackend.UserResolver.all/2
    end
  end
end

Now, we'll need to create our UserResolver module, which actually resolves our query and returns the requested data:

mkdir web/resolvers
vim web/resolvers/user_resolver.ex
defmodule TimeTrackerBackend.UserResolver do
  alias TimeTrackerBackend.{Repo, User}
  # We won't do anything with any arguments that are passed into this query for
  # now.
  def all(_args, _info) do
    {:ok, Repo.all(User)}
  end
end

This has defined our schema. From here, we can just tell our app's router about the schema and it'll be wired up for external consumption:

vim web/router.ex
defmodule TimeTrackerBackend.Router do
  use TimeTrackerBackend.Web, :router
  # ...
  forward "/graphql", Absinthe.Plug,
    schema: TimeTrackerBackend.Schema
  # ...
end

For this to work, we'll need to have the absinthe_plug package installed though. Let's add it:

vim mix.exs
defmodule TimeTrackerBackend.Mixfile do
  # ...
  def application do
    [ # ...
      applications: [
        # ...
        :absinthe_plug,
      ]
    ]
  end
  # ...
  defp deps do
    [ # ...
      {:absinthe_plug, "~> 1.2.0"},
    ]
  end
  # ...
end
mix deps.get

Now requests to the /graphql endpoint will resolve to our schema. Let's run the app and try it out with graphiql:

mix phoenix.server

We can install the Mac app for GraphiQL through brew to test it out:

brew cask install graphiql

Using our endpoint

When we run it and look at our endpoint, we can see that it can introspect our documentation already:

GraphiQL introspecting our new Absinthe-powered GraphQL endpoint

Let's make a query:

{
  users {
    id
  }
}

That gives us all of the users in our system. First off, it's pretty awesome that it's that easy. However, an id isn't a lot to go off of. Let's also get the names:

{
  users {
    id
    name
  }
}

You could see the autocompletion in action, a little bit. It wasn't that nice though: Self descriptive.

Autocompleting our User name field

Documenting our types

We can do a little better than that. Let's add a description for this field so our API users don't hate us:

defmodule TimeTrackerBackend.Schema.Types do
  # ...
  object :user do
    # ...
    field :name, :string, description: "The user's earth name"
    # ...
  end
end

We can tweak the server URL in the client so that it re-fetches the initial schema data from our backend, and now in the docs you can see this field has a description. But our user type itself could also use a description. We can fix that too:

defmodule TimeTrackerBackend.Schema.Types do
  # ...
  @desc "A user of our system"
  object :user do
    # ...
  end
end

If we make the client refresh again, we can see the user's description in the documentation.

Getting a single user

A list of users is nice, but it would also be nice to be able to fetch a single user by id. We'll need to update the schema to add support for that:

defmodule TimeTrackerBackend.Schema do
  # ...
  query do
    # ...
    field :user, type: :user do
      # We have to specify the arguments we accept in a given query
      arg :id, non_null(:id)
      resolve &TimeTrackerBackend.UserResolver.find/2
    end
  end
end

We'll need to add this function to our UserResolver:

defmodule TimeTrackerBackend.UserResolver do
  # ...
  # We can look into the arguments provided to find the id we're interested in
  def find(%{id: id}, _info) do
    # Then we just use Ecto to get the specified user, and return either an `ok`
    # or an `error` response accordingly.
    case Repo.get(User, id) do
      nil -> {:error, "No user found with id #{id}"}
      user -> {:ok, user}
    end
  end
end

Now we can make the client refresh the schema and try to find a particular user:

{
  user(id: "19"){
    id
    name
  }
}

That works!

Documenting our queries

We can move on to documenting our queries as well:

defmodule TimeTrackerBackend.Schema do
  # ...
  query do
    @desc "Get a list of users"
    field :users, list_of(:user) do
      # ...
    end

    @desc "Get a single user"
    field :user, type: :user do
      # ...
    end
  end
end

And if we refresh the server's data in GraphiQL we can see that the documentation reflects these descriptions now.

Documenting our queries

Mutations

GraphQL isn't just good for reading data though. We can define what GraphQL refers to as Mutations to allow users to modify the graph we're exposing. Let's add the ability to create a user.

We'll open up the schema and add a mutation:

defmodule TimeTrackerBackend.Schema do
  # ...
  mutation do
    @desc "Create a user"
    field :user, type: :user do
      arg :name, non_null(:string)
      arg :gender, non_null(:string)
      arg :email, non_null(:string)
      arg :username, non_null(:string)
      arg :avatar, non_null(:string)

      resolve &TimeTrackerBackend.UserResolver.create/2
    end
  end
  # ...
end

And we'll add the corresponding function to our UserResolver:

defmodule TimeTrackerBackend.UserResolver do
  # ...
  def create(args, _info) do
    changeset = User.changeset(%User{}, args)

    case Repo.insert(changeset) do
      {:ok, user} -> {:ok, user}
      {:error, changeset} -> {:error, inspect(changeset.errors)}
    end
  end
end

Now we can create a mutation and run it:

mutation CreateUser {
  user(
    name:"Josh",
    avatar:"nope",
    gender:"male",
    email:"josh@dailydrip.com",
    username:"knewter"
  ){
    id
  }
}

When we try to run this, we get an error:

{
  "errors": [
    {
      "message": "In field \"user\": [password: {\"can't be blank\", []}]",
      "locations": [
        {
          "line": 16,
          "column": 0
        }
      ]
    }
  ],
  "data": {}
}

We weren't able to create the user due to a requirement that users have passwords. We'll add the password field to this mutation in our schema:

defmodule TimeTrackerBackend.Schema do
  # ...
  mutation do
    @desc "Create a user"
    field :user, type: :user do
      # ...
      arg :password, non_null(:string)
      # ...
    end
  end
  # ...
end

And include it in our mutation:

mutation CreateUser {
  user(
    name:"Josh",
    avatar:"nope",
    gender:"male",
    email:"josh@dailydrip.com",
    username:"knewter",
    password:"secret"
  ){
    id
  }
}

This time it succeeds!

Testing Absinthe

You can also test your Absinthe schema fairly easily. Here's an example test I wrote for all of these queries.

Summary

Today we took a quick look at using Absinthe to create GraphQL endpoints, and mounted one in an existing Phoenix application. We then interacted with the API and saw how nice the documentation and introspection is for GraphQL. I'm really excited about GraphQL but I haven't yet found a chance to use it. I'm hoping to change that very soon. I hope you enjoyed it - see you soon!

Resources