Intro

Previously, we built the data model for a Forum as one app in an umbrella application. Today we'll add a Phoenix app to the umbrella and use Absinthe to provide access to the data model via GraphQL for end users.

Setup

We'll begin by creating the Phoenix application itself. We don't need Ecto, because our data layer manages the interaction with the database, and we don't need webpack

cd apps
# If you don't already have the latest installer
mix archive.install hex phx_new 1.4.0
mix phx.new firestorm --no-ecto --no-webpack
cd firestorm

Adding Absinthe

Now that we have a Phoenix app, we'd like to introduce our initial dependencies. We need absinthe and absinthe_plug for absinthe itself. We also depend on our data layer from the umbrella.

# mix.exs
defmodule Firestorm.Mixfile do
  # ...
  defp deps do
    [
      # ...
      {:absinthe, "~> 1.4"},
      {:absinthe_plug, "~> 1.4"},
      {:firestorm_data, in_umbrella: true}
    ]
  end
end

We'll fetch the dependencies:

mix deps.get

In our router, we'll forward requests to /graphql to the Absinthe.Plug.

# lib/firestorm_web/router.ex
defmodule FirestormWeb.Router do
  # ...
  forward(
    "/graphql",
    Absinthe.Plug,
    schema: FirestormWeb.Schema,
    json_codec: Jason
  )
end

Our first query:~categories~

We're going to start by listing categories. This means we'll need to define a category type:

mkdir lib/firestorm_web/schema
# lib/firestorm_web/schema/categories_types.ex
defmodule FirestormWeb.Schema.CategoriesTypes do
  use Absinthe.Schema.Notation

  object :category do
    field(:id, non_null(:id))
    field(:title, non_null(:string))
  end
end

Now let's import this type into the schema and add a query to list all categories:

# lib/firestorm_web/schema.ex
defmodule FirestormWeb.Schema do
  # It's an absinthe schema
  use Absinthe.Schema
  # We import the types we want to reference
  import_types(FirestormWeb.Schema.CategoriesTypes)

  alias FirestormData.Categories

  # We add the `RootQueryType` containing a single field for
  # `categories` to start.
  query do
    @desc "Get all categories"
    # We'll use our context to get the categories
    field(:categories, non_null(list_of(non_null(:category)))) do
      resolve(fn _, _, _ ->
        Categories.list_categories()
      end)
    end
  end
end

That should be sufficient to load our categories and return them with GraphQL. Pretty easy!

Testing a query

Let's see what happens when we run this query by adding a test.

mkdir -p test/absinthe/queries
# test/absinthe/queries/categories_test.exs
defmodule Firestorm.Absinthe.Queries.CategoriesTest do
  use FirestormWeb.ConnCase
  alias FirestormWeb.Schema
  alias FirestormData.Categories

  test "getting a list of categories" do
    # we insert a category to fetch in the query
    {:ok, category} = Categories.create_category(%{title: "Some title"})

    # we execute the root query fetching the `categories` field, and inside that field we fetch the `id` and `title` of each category.
    query = """
    {
      categories {
        id
        title
      }
    }
    """

    # Eventually we will want to pass a context in. Contexts are used in absinthe to hold data about the request - this is where the logged in user will appear in the future.
    context = %{}

    # We can execute a query via `Absinthe.run`. This runs the query against the schema with the specified context, without having to go through the HTTP layer
    {:ok, %{data: %{"categories" => categories}}} = Absinthe.run(query, Schema, context: context)

    first_category = hd(categories)
    assert first_category["title"] == category.title
    assert first_category["id"] == category.id
  end
end

If we run the test, it fails with:

** (Absinthe.ExecutionError) Invalid value returned from resolver.

Resolving field:

    categories

Defined at:

    /Users/jadams/tmp/firestorm/apps/firestorm/lib/firestorm_web/schema.ex:14

Resolving on:

    %{}

Got value:

    [%FirestormData.Categories.Category{__meta__: #Ecto.Schema.Metadata<:loaded, "firestorm_categories_categories">, id: "a6ba26cf-4483-4633-84b4-4533a977e367", inserted_at: #DateTime<2018-11-17 00:19:09.499775Z>, threads: #Ecto.Association.NotLoaded<association :threads is not loaded>, title: "Some title", updated_at: #DateTime<2018-11-17 00:19:09.499775Z>}, %FirestormData.Categories.Category{__meta__: #Ecto.Schema.Metadata<:loaded, "firestorm_categories_categories">, id: "41641601-6bb6-465f-9c4b-810fb5e50102", inserted_at: #DateTime<2018-11-17 00:29:55.447025Z>, threads: #Ecto.Association.NotLoaded<association :threads is not loaded>, title: "Some title", updated_at: #DateTime<2018-11-17 00:29:55.447025Z>}, %FirestormData.Categories.Category{__meta__: #Ecto.Schema.Metadata<:loaded, "firestorm_categories_categories">, id: "7cda8942-82a2-4c69-abba-a1f06072d88a", inserted_at: #DateTime<2018-11-17 00:30:24.096977Z>, threads: #Ecto.Association.NotLoaded<association :threads is not loaded>, title: "Some title", updated_at: #DateTime<2018-11-17 00:30:24.096977Z>}]

We didn't return a valid response in our resolver function. The contract is that we return an :ok or :error tuple. Let's wrap the response in an :ok tuple:

defmodule FirestormWeb.Schema do
  # ...
  query do
    @desc "Get all categories"
    # We'll use our context to get the categories
    field(:categories, list_of(:category)) do
      resolve(fn _, _, _ ->
        {:ok, Categories.list_categories()}
      end)
    end
  end
end

If we run the tests again, they fail. They fail because the previously-inserted content is still in the test database - we haven't configured the phoenix application to use the ecto sandbox. Let's do that:

# test/support/conn_case.ex
defmodule FirestormWeb.ConnCase do
  # ...
  setup tags do
    :ok = Ecto.Adapters.SQL.Sandbox.checkout(FirestormData.Repo, ownership_timeout: 30000)

    unless tags[:async] do
      Ecto.Adapters.SQL.Sandbox.mode(FirestormData.Repo, {:shared, self()})
    end

    {:ok, conn: Phoenix.ConnTest.build_conn()}
  end
end

We should drop and recreate the database and migrate it:

MIX_ENV=test mix do ecto.drop, ecto.create, ecto.migrate

This fails because the mix task doesn't know what repo it should be talking to.

warning: could not find Ecto repos in any of the apps: [:firestorm].

You can avoid this warning by passing the -r flag or by setting the
repositories managed by those applications in your config/config.exs:

    config :firestorm, ecto_repos: [...]

We can just do what the error tells us to do:

# config/config.exs
config :firestorm,
  ecto_repos: [FirestormData.Repo]

Now recreate the test database:

MIX_ENV=test mix do ecto.drop, ecto.create, ecto.migrate

Now the test passes.

Next up - finding a specific category by id

Now that we can list categories, we would like to fetch a specific category by id. Since we've seen how to test absinthe queries, let's start with a test:

defmodule Firestorm.Absinthe.Queries.CategoriesTest do
  # ...
  test "getting a category by id" do
    # We'll add a category
    {:ok, category} = Categories.create_category(%{title: "Some title"})

    # Then fetch it by id
    query = """
    {
      category(id: "#{category.id}") {
        id
        title
      }
    }
    """

    context = %{}

    {:ok, %{data: %{"category" => returned_category}}} =
      Absinthe.run(query, Schema, context: context)

    assert returned_category["id"] == category.id
    assert returned_category["title"] == category.title
  end
end

If we run the test, we get an error:

** (MatchError) no match of right hand side value: {:ok, %{errors: [%{locations: [%{column: 0, line: 2}], message: "Cannot query field \"category\" on type \"RootQueryType\". Did you mean \"categories\"?"}, %{locations: [%{column: 0, line: 2}], message: "Unknown argument \"id\" on field \"category\" of type \"RootQueryType\"."}]}}

There's no `category` field in the schema. Let's add it:

defmodule FirestormWeb.Schema do
  # ...
  alias FirestormWeb.Resolvers

  query do
    # ...
    @desc "Get a specific category"
    field(:category, non_null(:category)) do
      arg(:id, non_null(:id))
      resolve(&Resolvers.Categories.find_category/3)
    end
  end
end

The category field requires an :id argument to specify which category we'd like to fetch.

This time we'll introduce a bit of organization - rather than creating an inline resolver function, we'll put the function into a `FirestormWeb.Resolvers.Categories` module.

Let's create that module:

mkdir lib/firestorm_web/resolvers
defmodule FirestormWeb.Resolvers.Categories do
  def find_category(_, %{id: id}, _) do
    FirestormData.Categories.find_category(%{id: id})
  end
end

With that, the test passes and we can find a particular category.

Listing a category's threads

When viewing a category in the forum, we expect to see a list of threads. Let's add a threads field on the category object:

defmodule Firestorm.Absinthe.Queries.CategoriesTest do
  # ...
  alias FirestormData.{
    Categories,
    Threads,
    Users
  }
  # ...
  test "getting a category by id" do
    {:ok, category} = Categories.create_category(%{title: "Some title"})

    {:ok, user} =
      Users.create_user(%{
        username: "knewter",
        name: "Josh Adams",
        email: "josh@smoothterminal.com"
      })

    {:ok, thread} =
      Threads.create_thread(category, user, %{title: "Thread title", body: "First post"})

    query = """
    {
      category(id: "#{category.id}") {
        id
        title
        threads {
          id
          title
        }
      }
    }
    """

    context = %{}

    {:ok, %{data: %{"category" => returned_category}}} =
      Absinthe.run(query, Schema, context: context)

    assert returned_category["id"] == category.id
    assert returned_category["title"] == category.title
    assert [first_thread] = returned_category["threads"]
    assert first_thread["id"] == thread.id
    assert first_thread["title"] == thread.title
  end
end

We need to add the field to the category object in the schema:

# lib/firestorm_web/schema/categories_types.ex
defmodule FirestormWeb.Schema.CategoriesTypes do
  # ...
  alias FirestormWeb.Resolvers

  object :category do
    # ...
    field :threads, non_null(list_of(non_null(:thread))) do
      resolve(&Resolvers.Threads.list_threads/3)
    end
  end
end
# lib/firestorm_web/resolvers/threads.ex
defmodule FirestormWeb.Resolvers.Threads do
  alias FirestormData.{
    Categories.Category,
    Threads
  }

  def list_threads(%Category{} = category, _, _) do
    {:ok, Threads.list_threads(category)}
  end
end
# lib/firestorm_web/schema/threads_types.ex
defmodule FirestormWeb.Schema.ThreadsTypes do
  use Absinthe.Schema.Notation

  object :thread do
    field(:id, non_null(:id))
    field(:title, non_null(:string))
  end
end
# lib/firestorm_web/schema.ex
defmodule FirestormWeb.Schema do
  # ...
  import_types(FirestormWeb.Schema.ThreadsTypes)
end

Now if we run the tests, they pass.

Fetching a specific thread

Now we can list threads, so of course we'll want to visit a particular thread. Let's add a test for that:

defmodule Firestorm.Absinthe.Queries.ThreadsTest do
  use FirestormWeb.ConnCase
  alias FirestormWeb.Schema

  alias FirestormData.{
    Categories,
    Threads,
    Users
  }

  test "getting a thread by id" do
    {:ok, category} = Categories.create_category(%{title: "Some title"})

    {:ok, user} =
      Users.create_user(%{
        username: "knewter",
        name: "Josh Adams",
        email: "josh@smoothterminal.com"
      })

    {:ok, thread} =
      Threads.create_thread(category, user, %{title: "Thread title", body: "First post"})

    query = """
    {
      thread(id: "#{thread.id}") {
        id
        title
      }
    }
    """

    context = %{}

    {:ok, %{data: %{"thread" => returned_thread}}} = Absinthe.run(query, Schema, context: context)

    assert returned_thread["id"] == thread.id
    assert returned_thread["title"] == thread.title
  end
end

If we run the test, of course it fails as there's no thread query. Let's add it:

# lib/firestorm_web/schema.ex
defmodule FirestormWeb.Schema do
  # ...
  query do
    # ...
    @desc "Get a specific thread"
    field(:thread, non_null(:thread)) do
      arg(:id, non_null(:id))
      resolve(&Resolvers.Threads.find_thread/3)
    end
  end
end
# lib/firestorm_web/resolvers/threads.ex
defmodule FirestormWeb.Resolvers.Threads do
  alias FirestormData.{
    Categories.Category,
    Threads
  }

  def list_threads(%Category{} = category, _, _) do
    {:ok, Threads.list_threads(category)}
  end

  def find_thread(_, %{id: id}, _) do
    Threads.get_thread(id)
  end
end

Now we can find a thread.

Showing a thread's posts

Of course when looking at a thread we want to see the posts. Let's add a posts field to the thread type.

# test/absinthe/queries/threads_test.exs
defmodule Firestorm.Absinthe.Queries.ThreadsTest do
  # ...
  test "getting a thread by id" do
    # ...
    query = """
    {
      thread(id: "#{thread.id}") {
        id
        title
        posts {
          id
          body
        }
      }
    }
    """

    context = %{}

    {:ok, %{data: %{"thread" => returned_thread}}} = Absinthe.run(query, Schema, context: context)

    assert returned_thread["id"] == thread.id
    assert returned_thread["title"] == thread.title
    assert [first_post] = returned_thread["posts"]
    assert first_post["body"] == "First post"
  end
end

If we run the tests, they fail because there's no posts field on the thread type. We'll add it:

# lib/firestorm_web/schema/threads_types.ex
defmodule FirestormWeb.Schema.ThreadsTypes do
  # ...
  alias FirestormWeb.Resolvers

  object :thread do
    # ...
    field(:posts, non_null(list_of(non_null(:post)))) do
      resolve(&Resolvers.Posts.list_posts/3)
    end
  end
end
defmodule FirestormWeb.Resolvers.Posts do
  alias FirestormData.{
    Posts,
    Threads.Thread
  }

  def list_posts(%Thread{} = thread, _, _) do
    {:ok, Posts.list_posts(thread)}
  end
end
defmodule FirestormWeb.Schema.PostsTypes do
  use Absinthe.Schema.Notation

  object :post do
    field(:id, non_null(:id))
    field(:body, non_null(:string))
  end
end
# lib/firestorm_web/schema.ex
defmodule FirestormWeb.Schema do
  # ...
  import_types(FirestormWeb.Schema.PostsTypes)
  # ...
end

Now we can see a thread's posts.

Adding a datetime scalar

We'd like to know when posts were created so we can show them in the frontend. To do this, we'll introduce a new datetime scalar in our GraphQL schema.

defmodule FirestormWeb.Schema do
  # ...
  @desc """
  The `DateTime` scalar type represents a date and time in the UTC
  timezone. The DateTime appears in a JSON response as an ISO8601 formatted
  string, including UTC timezone ("Z"). The parsed date and time string will
  be converted to UTC and any UTC offset other than 0 will be rejected.
  """
  # Defining a new scalar requires specifying a serialize and a parse function.
  scalar :datetime, name: "DateTime" do
    # We will serialize a datetime to iso8601
    serialize(&DateTime.to_iso8601/1)
    # We will define a `parse_datetime/1` function for parsing
    parse(&parse_datetime/1)
  end

  # Parsing a datetime will use DateTime.from_iso8601
  @spec parse_datetime(Absinthe.Blueprint.Input.String.t()) :: {:ok, DateTime.t()} | :error
  @spec parse_datetime(Absinthe.Blueprint.Input.Null.t()) :: {:ok, nil}
  defp parse_datetime(%Absinthe.Blueprint.Input.String{value: value}) do
    case DateTime.from_iso8601(value) do
      {:ok, datetime, 0} -> {:ok, datetime}
      {:ok, _datetime, _offset} -> :error
      _error -> :error
    end
  end

  defp parse_datetime(%Absinthe.Blueprint.Input.Null{}) do
    {:ok, nil}
  end

  defp parse_datetime(_) do
    :error
  end
  # ...
end

We'll use this to add inserted_at and updated_at fields for each of our types:

defmodule FirestormWeb.Schema.CategoriesTypes do
  # ...
  object :category do
    # ...
    field(:inserted_at, non_null(:datetime))
    field(:updated_at, non_null(:datetime))
  end
end
defmodule FirestormWeb.Schema.ThreadsTypes do
  # ...
  object :thread do
    # ...
    field(:inserted_at, non_null(:datetime))
    field(:updated_at, non_null(:datetime))
  end
end
defmodule FirestormWeb.Schema.PostsTypes do
  # ...
  object :post do
    # ...
    field(:inserted_at, non_null(:datetime))
    field(:updated_at, non_null(:datetime))
  end
end

We'll ensure that we can fetch these fields for posts in our tests:

defmodule Firestorm.Absinthe.Queries.ThreadsTest do
  # ...
  test "getting a thread by id" do
    # ...
    query = """
    {
      thread(id: "#{thread.id}") {
        id
        title
        posts {
          id
          body
          insertedAt
          updatedAt
        }
      }
    }
    """
    # ...
    assert first_post["insertedAt"]
    assert first_post["updatedAt"]
  end
end

That works. Now you know how to add new scalar types. For what it's worth, in the future Ι will probably serialize these fields using POSIX time instead.

Adding users

Posts are posted by a User. We should add a user type and expose the post's user field:

defmodule Firestorm.Absinthe.Queries.ThreadsTest do
  # ...
  test "getting a thread by id" do
    # ...
    query = """
    {
      thread(id: "#{thread.id}") {
        id
        title
        posts {
          id
          body
          insertedAt
          updatedAt
          user {
            id
            name
            username
          }
        }
      }
    }
    """
    # ...
    assert first_post["user"]["id"] == user.id
    assert first_post["user"]["name"] == user.name
    assert first_post["user"]["username"] == user.username
  end
end

The test fails because posts don't have a user field, so we'll add it:

defmodule FirestormWeb.Schema.PostsTypes do
  # ...
  object :post do
    # ...
    field :user, non_null(:user) do
      resolve(fn post, _, _ ->
        # NOTE:This is awful as it is an explicit N+1 query. We'll deal with it later.
        {:ok, FirestormData.Repo.preload(post, :user).user}
      end)
    end
    # ...
  end
end

We need a user type:

defmodule FirestormWeb.Schema.UsersTypes do
  use Absinthe.Schema.Notation

  object :user do
    field(:id, non_null(:id))
    field(:username, non_null(:string))
    field(:name, non_null(:string))
    field(:inserted_at, non_null(:datetime))
    field(:updated_at, non_null(:datetime))
  end
end
defmodule FirestormWeb.Schema do
  # ...
  import_types(FirestormWeb.Schema.UsersTypes)
end

With this code, the test passes.

Introducing Dataloader

While implementing the user field, we noticed a pretty obvious N+1 query. How do we avoid this? Dataloader.

Dataloader will keep track of things it's already seen and avoid refetching them, as well as batch requests to fetch in bulk. It's exactly what we need in this situation.

First, we need to add the dependency:

defmodule Firestorm.MixProject do
  # ...
  defp deps do
    [
      # ...
      {:dataloader, "~> 1.0"},
      # ...
    ]
  end
end
mix deps.get

We start by bringing it into our schema:

defmodule FirestormWeb.Schema do
  # ...
  # We need to add the Dataloader plugin
  def plugins do
    [Absinthe.Middleware.Dataloader | Absinthe.Plugin.defaults()]
  end

  # And each request's context will need to have the loader configured. We'll add a source for posts, adding the corresponding module momentarily.
  def context(ctx) do
    loader =
      Dataloader.new()
      |> Dataloader.add_source(FirestormData.Posts.Post, FirestormWeb.Loaders.Posts.data())

    Map.put(ctx, :loader, loader)
  end
  # ...
end

Now we'll create the loader:

mkdir lib/firestorm_web/loaders
# lib/firestorm_web/loaders/posts.ex
defmodule FirestormWeb.Loaders.Posts do
  def data do
    Dataloader.Ecto.new(FirestormData.Repo)
  end
end

This is a bit of 'overengineering' for what we're doing for now, but it gives us a nice pattern for adding filtering and ordering per-source later so I always set things up this way. I found Dataloader confusing at first, but for now you can just trust that it's magic that works to resolve this N+1 for us and dig into it in more detail later. Now we need to use this Dataloader source when we fetch a post's user:

defmodule FirestormWeb.Schema.PostsTypes do
  # ...
  import Absinthe.Resolution.Helpers

  object :post do
    # ...
    field(:user, non_null(:user), resolve: dataloader(FirestormData.Posts.Post))
    # ...
  end
end

If we run the tests now, they still pass. Now we're letting Dataloader find the post's user for us, and it's taking care of avoiding N+1 queries.

Adding Gravatar

When we show user information, we'd like to see an avatar. Gravatar's been around forever, so we'll use that. First, we'll add a test that we can fetch an avatarUrl field on a user:

defmodule Firestorm.Absinthe.Queries.ThreadsTest do
  # ...
  test "getting a thread by id" do
    # ...
    query = """
    {
      thread(id: "#{thread.id}") {
        id
        title
        posts {
          id
          body
          insertedAt
          updatedAt
          user {
            id
            name
            username
            avatarUrl
          }
        }
      }
    }
    """
    # ...
    assert first_post["user"]["avatarUrl"]
  end
end

This will of course fail as there is no such field. Let's add it:

# lib/firestorm_web/schema/users_types.ex
defmodule FirestormWeb.Schema.UsersTypes do
  # ...
  alias FirestormWeb.Resolvers

  object :user do
    # ...
    field(:avatar_url, non_null(:string), resolve: &Resolvers.Users.gravatar/3)
    # ...
  end
end
# lib/firestorm_web/resolvers/users.ex
defmodule FirestormWeb.Resolvers.Users do
  def gravatar(user, _, _) do
    {:ok, Gravity.image(user.email)}
  end
end

Here I'm using the Gravity package. We need to bring in the dependency:

# mix.exs
defmodule Firestorm.MixProject do
  # ...
  defp deps do
    [
      # ...
      {:gravity, "~> 1.0"},
      # ...
    ]
  end
end
mix deps.get

If we run the tests, they pass.

Our first mutation - creating a user

Now that we can browse the content of our forum, it would be nice to create some of that content. Before we can create anything, we'll need to be authenticated. Let's add a createUser mutation, starting with a test:

mkdir test/absinthe/mutations
defmodule Firestorm.Absinthe.Mutations.UsersTest do
  use FirestormWeb.ConnCase
  alias FirestormWeb.Schema

  @email "josh@smoothterminal.com"
  @name "Josh Adams"
  @username "knewter"
  @password "password"

  test "creating a user" do
    query = """
    mutation {
      createUser(email: "#{@email}", name: "#{@name}", username: "#{@username}", password: "#{
        id
        name
        username
        avatarUrl
      }
    }
    """

    context = %{}

    {:ok, %{data: %{"createUser" => user}}} = Absinthe.run(query, Schema, context: context)

    assert user["id"]
    assert user["name"] == @name
    assert user["username"] == @username
    assert user["avatarUrl"] == Gravity.image(@email)
  end
end

The test will fail because we have no createUser mutation defined. Let's add that:

# lib/firestorm_web/schema.ex
defmodule FirestormWeb.Schema do
  # ...
  mutation do
    @desc "Create a user"
    field(:create_user, non_null(:user)) do
      arg(:username, non_null(:string))
      arg(:name, non_null(:string))
      arg(:email, non_null(:string))
      arg(:password, non_null(:string))
      resolve(&Resolvers.Users.create_user/3)
    end
  end
end
# lib/firestorm_web/resolvers/users.ex
defmodule FirestormWeb.Resolvers.Users do
  # ...
  def create_user(_, args, _) do
    FirestormData.Users.create_user(args)
  end
end

The test passes. We can create users now.

Authentication

Now that we can create a user, let's add authentication. We'll add an authenticate mutation that returns a token. A client will then provide that token to us as a Bearer Token in order to identify themselves.

We'll start with a test:

defmodule Firestorm.Absinthe.Mutations.UsersTest do
  # ...
  alias FirestormData.Users
  # ...
  test "authentication" do
    {:ok, user} =
      Users.create_user(%{
        username: @username,
        email: @email,
        password: @password,
        name: @name
      })

    query = """
    mutation {
      authenticate(email: "#{@email}", password: "#{@password}")
    }
    """

    context = %{}

    {:ok, %{data: %{"authenticate" => token}}} = Absinthe.run(query, Schema, context: context)

    assert {:ok, user.id} ==
             Phoenix.Token.verify(FirestormWeb.Endpoint, "user auth", token, max_age: 86400)
  end
end

Here we're authenticating and then verifying that the returned token is signed by our endpoint and encodes the corresponding user's id.

Let's add the mutation:

# lib/firestorm_web/schema.ex
defmodule FirestormWeb.Schema do
    # ...
    @desc "Authenticate and receive an authorization token"
    field(:authenticate, non_null(:string)) do
      arg(:email, non_null(:string))
      arg(:password, non_null(:string))
      resolve(&Resolvers.Users.authenticate/3)
    end
  end
end
# lib/firestorm_web/resolvers/users.ex
defmodule FirestormWeb.Resolvers.Users do
  # ...
  def authenticate(_, %{email: email, password: password}, _) do
    with {:ok, user} <- FirestormData.Users.find_user(%{email: email}) do
      # FIXME:This is a bit of data leakage - why does the graphql interface know that the data layer uses Comeonin.Bcrypt?
      case Comeonin.Bcrypt.checkpw(password, user.password_hash) do
        true ->
          # Everything checks out, success
          {:ok, sign_auth_token(user.id)}

        _ ->
          # User existed, we checked the password, but no dice
          {:error, "No user found with that username or password"}
      end
    end
  end

  defp sign_auth_token(id), do: Phoenix.Token.sign(FirestormWeb.Endpoint, "user auth", id)
end

Now our test passes, and a user can get an authentication token.

Creating a category

Now let's add a mutation to create a category. We'll also require a user to be logged in in order to do create a category. Let's write a test first:

defmodule Firestorm.Absinthe.Mutations.CategoriesTest do
  use FirestormWeb.ConnCase
  alias FirestormWeb.Schema
  alias FirestormData.Users

  @email "josh@smoothterminal.com"
  @name "Josh Adams"
  @username "knewter"
  @password "password"
  @title "Some category"

  test "creating a category without a user fails" do
    context = %{}

    {:ok, %{errors: errors}} = Absinthe.run(create_category_query(), Schema, context: context)

    assert "unauthorized" in Enum.map(errors, & &1.message)
  end

  test "creating a category" do
    {:ok, user} =
      Users.create_user(%{
        username: @username,
        email: @email,
        password: @password,
        name: @name
      })

    context = %{current_user: user}

    {:ok, %{data: %{"createCategory" => category}}} =
      Absinthe.run(create_category_query(), Schema, context: context)

    assert category["id"]
    assert category["title"] == @title
  end

  defp create_category_query do
    """
    mutation {
      createCategory(title: "#{@title}") {
        id
        title
      }
    }
    """
  end
end

We need to add the mutation to the schema:

defmodule FirestormWeb.Schema do
  # ...
  mutation do
    # ...
    @desc "Create a category"
    field(:create_category, non_null(:category)) do
      arg(:title, non_null(:string))
      resolve(&Resolvers.Categories.create_category/3)
    end
  end
end

Then we define the function in the categories resolver:

defmodule FirestormWeb.Resolvers.Categories do
  alias FirestormData.Users.User
  # ...
  # If the context has a current user, we allow the mutation
  def create_category(_, args, %{context: %{current_user: %User{}}}) do
    FirestormData.Categories.create_category(args)
  end

  # Otherwise, we return an error
  def create_category(_, _, _) do
    {:error, "unauthorized"}
  end
end

Now the tests pass. But how does the context's current user get set?

Setting the current user in the absinthe context

We can set the current user in a plug. Let's introduce a FirestormWeb.Context module to extract the user from the authorization header:

defmodule FirestormWeb.Context do
  import Plug.Conn
  @behaviour Plug
  @one_day 86_400

  def init(opts), do: opts

  def call(conn, _) do
    context = build_context(conn)
    Absinthe.Plug.put_options(conn, context: context)
  end

  def build_context(conn) do
    conn
    |> get_req_header("authorization")
    |> case do
      ["Bearer " <> token] ->
        %{current_user: get_user(token)}

      _ ->
        %{current_user: nil}
    end
  end

  def get_user(nil), do: nil

  def get_user(user_token) do
    case Phoenix.Token.verify(FirestormWeb.Endpoint, "user auth", user_token, max_age: @one_day) do
      {:ok, user_id} ->
        case FirestormData.Users.get_user(user_id) do
          {:ok, user} -> user
          _ -> nil
        end

      _ ->
        nil
    end
  end
end

We'll add this plug in the router:

defmodule FirestormWeb.Router do
  # ...
  pipeline :graphql do
    plug(FirestormWeb.Context)
  end
  # ...
  scope "/graphql" do
    pipe_through(:graphql)

    forward(
      "/",
      Absinthe.Plug,
      schema: FirestormWeb.Schema,
      json_codec: Jason
    )
  end
  # ...
end

Our unit tests explicitly pass the context to Absinthe.run, but we should verify that our plug sets the context correctly. We can do this by running the following queries in GraphQL Playground:

# Run this mutation to create a user
mutation CreateUser{
  createUser(name:"Josh Adams", email:"josh@smoothterminal.com", username:"knewter", password:"password"){
    id
    name
    username
  }
}

# Authenticate and copy the resulting token
mutation Authenticate{
  authenticate(email:"josh@smoothterminal.com",password:"password")
}

# Set the `authorization` header to "Bearer #{token}", then create a category. If it succeeds, the plug works.
mutation CreateCategory{
  createCategory(title:"Hey look a new category!"){
    id
    title
  }
}

If you can run these queries successfully, then the plug works to set the context up.

Creating a thread

Now that we can create categories, we should create a new thread. We'll add a test:

# test/absinthe/mutations/threads_test.exs
defmodule Firestorm.Absinthe.Mutations.ThreadsTest do
  use FirestormWeb.ConnCase
  alias FirestormWeb.Schema

  alias FirestormData.{
    Categories,
    Users
  }

  @email "josh@smoothterminal.com"
  @name "Josh Adams"
  @username "knewter"
  @password "password"
  @category_title "Some category"
  @title "Some thread"
  @body "First post"

  setup do
    {:ok, category} = Categories.create_category(%{title: @category_title})

    {:ok, user} =
      Users.create_user(%{
        username: @username,
        email: @email,
        password: @password,
        name: @name
      })

    {:ok, category: category, user: user}
  end

  test "creating a thread without a user fails", %{category: category} do
    context = %{}

    {:ok, %{errors: errors}} =
      Absinthe.run(create_thread_query(category), Schema, context: context)

    assert "unauthorized" in Enum.map(errors, & &1.message)
  end

  test "creating a thread", %{
    category: category,
    user: user
  } do
    context = %{current_user: user}

    {:ok, %{data: %{"createThread" => thread}}} =
      Absinthe.run(create_thread_query(category), Schema, context: context)

    assert thread["id"]
    assert thread["title"] == @title
    assert [first_post] = thread["posts"]
    assert first_post["id"]
    assert first_post["body"] == @body
    assert first_post["user"]["username"] == @username
    assert first_post["user"]["name"] == @name
  end

  defp create_thread_query(category) do
    """
    mutation {
      createThread(title: "#{@title}", body: "#{@body}", categoryId: "#{category.id}") {
        id
        title
        posts {
          id
          body
          user {
            id
            username
            name
          }
        }
      }
    }
    """
  end
end

We'll add the mutation:

defmodule FirestormWeb.Schema do
  # ...
  mutation do
    # ...
    @desc "Create a thread"
    field(:create_thread, non_null(:thread)) do
      arg(:category_id, non_null(:id))
      arg(:title, non_null(:string))
      arg(:body, non_null(:string))
      resolve(&Resolvers.Threads.create_thread/3)
    end
  end
end
defmodule FirestormWeb.Resolvers.Threads do
  alias FirestormData.{
    # ...
    Users.User
  }
  # ...
  def create_thread(_, %{category_id: category_id, title: title, body: body}, %{
        context: %{current_user: %User{} = current_user}
      }) do
    with {:ok, category} <- FirestormData.Categories.find_category(%{id: category_id}),
         do:
           FirestormData.Threads.create_thread(category, current_user, %{title: title, body: body})
  end

  def create_thread(_, _, _) do
    {:error, "unauthorized"}
  end
end

This gets our tests passing. Now we can create categories and threads.

Creating posts

Let's add the createPost mutation the same way, starting with a test:

defmodule Firestorm.Absinthe.Mutations.PostsTest do
  use FirestormWeb.ConnCase
  alias FirestormWeb.Schema

  alias FirestormData.{
    Categories,
    Threads,
    Users
  }

  @email "josh@smoothterminal.com"
  @name "Josh Adams"
  @username "knewter"
  @password "password"
  @category_title "Some category"
  @thread_title "Some thread"
  @first_post_body "First post"
  @body "Second post"

  setup do
    {:ok, category} = Categories.create_category(%{title: @category_title})

    {:ok, user} =
      Users.create_user(%{
        username: @username,
        email: @email,
        password: @password,
        name: @name
      })

    {:ok, thread} =
      Threads.create_thread(category, user, %{
        title: @thread_title,
        body: @first_post_body
      })

    {:ok, user: user, thread: thread}
  end

  test "creating a post without a user fails", %{
    thread: thread
  } do
    context = %{}

    {:ok, %{errors: errors}} = Absinthe.run(create_post_query(thread), Schema, context: context)

    assert "unauthorized" in Enum.map(errors, & &1.message)
  end

  test "creating a post", %{
    thread: thread,
    user: user
  } do
    context = %{current_user: user}

    {:ok, %{data: %{"createPost" => post}}} =
      Absinthe.run(create_post_query(thread), Schema, context: context)

    assert post["id"]
    assert post["body"] == @body
    assert post["user"]["username"] == @username
    assert post["user"]["name"] == @name
  end

  defp create_post_query(thread) do
    """
    mutation {
      createPost(body: "#{@body}", threadId: "#{thread.id}") {
        id
        body
        user {
          id
          username
          name
        }
      }
    }
    """
  end
end

We add the mutation to the schema:

defmodule FirestormWeb.Schema do
  # ...
  mutation do
    # ...
    @desc "Create a post"
    field(:create_post, non_null(:post)) do
      arg(:thread_id, non_null(:id))
      arg(:body, non_null(:string))
      resolve(&Resolvers.Posts.create_post/3)
    end
  end
end
defmodule FirestormWeb.Resolvers.Posts do
  alias FirestormData.{
    Posts,
    Threads.Thread,
    Users.User
  }

  def list_posts(%Thread{} = thread, _, _) do
    {:ok, Posts.list_posts(thread)}
  end

  def create_post(_, %{thread_id: thread_id, body: body}, %{
        context: %{current_user: %User{} = current_user}
      }) do
    with {:ok, thread} <- FirestormData.Threads.get_thread(thread_id),
         do: FirestormData.Posts.create_post(thread, current_user, %{body: body})
  end

  def create_post(_, _, _), do: {:error, :unauthorized}
end

Now we can create posts. That's every mutation we need.

Adding our first subscription - categoryAdded

Our clients would like to be notified when new categories, threads, and posts are created. Absinthe provides excellent support for GraphQL Subscriptions. Let's take advantage of absinthe's triggers to implement this for now.

We'll start with a test. First, we'll create an ExUnit case template for subscriptions to set up the socket:

# test/support/subscription_case.ex
defmodule FirestormWeb.SubscriptionCase do
  @moduledoc """
  This module defines the test case to be used by
  subscription tests.
  """

  use ExUnit.CaseTemplate

  using do
    quote do
      use FirestormWeb.ChannelCase
      use Absinthe.Phoenix.SubscriptionTest, schema: FirestormWeb.Schema
      alias FirestormData.Users

      setup do
        {:ok, user} =
          Users.create_user(%{
            email: "josh@smoothterminal.com",
            name: "Josh Adams",
            username: "knewter"
          })

        # When connecting to a socket, if you pass a token we will set the context's `current_user`
        params = %{
          "token" => sign_auth_token(user.id)
        }

        {:ok, socket} = Phoenix.ChannelTest.connect(FirestormWeb.UserSocket, params)
        {:ok, socket} = Absinthe.Phoenix.SubscriptionTest.join_absinthe(socket)

        {:ok, socket: socket, user: user}
      end

      defp sign_auth_token(id), do: Phoenix.Token.sign(FirestormWeb.Endpoint, "user auth", id)
    end
  end
end

We also need to update the ChannelCase to use the Ecto sandbox:

# test/support/channel_case.ex
defmodule FirestormWeb.ChannelCase do
  @moduledoc """
  This module defines the test case to be used by
  channel tests.

  Such tests rely on `Phoenix.ChannelTest` and also
  import other functionality to make it easier
  to build common data structures and query the data layer.

  Finally, if the test case interacts with the database,
  it cannot be async. For this reason, every test runs
  inside a transaction which is reset at the beginning
  of the test unless the test case is marked as async.
  """

  use ExUnit.CaseTemplate

  using do
    quote do
      # Import conveniences for testing with channels
      use Phoenix.ChannelTest

      # The default endpoint for testing
      @endpoint FirestormWeb.Endpoint
    end
  end

  setup tags do
    :ok = Ecto.Adapters.SQL.Sandbox.checkout(FirestormData.Repo, ownership_timeout: 30000)

    unless tags[:async] do
      Ecto.Adapters.SQL.Sandbox.mode(FirestormData.Repo, {:shared, self()})
    end
  end
end

We'll set up our socket to handle the authentication token:

# lib/firestorm_web/channels/user_socket.ex
defmodule FirestormWeb.UserSocket do
  @moduledoc false
  @one_day 86_400

  use Phoenix.Socket
  use Absinthe.Phoenix.Socket, schema: FirestormWeb.Schema

  alias Absinthe.Phoenix.Socket

  def connect(%{"token" => token}, socket) do
    with {:ok, user_id} <-
           Phoenix.Token.verify(FirestormWeb.Endpoint, "user auth", token, max_age: @one_day),
         {:ok, user} <- FirestormData.Users.get_user(user_id) do
      socket = Socket.put_options(socket, context: %{current_user: user})
      {:ok, socket}
    else
      _ -> :error
    end
  end

  def connect(_, socket) do
    {:ok, socket}
  end

  def id(%{assigns: %{absinthe: %{opts: [context: %{current_user: user}]}}}) do
    "user_socket:#{user.id}"
  end

  def id(_), do: nil
end

Now if you connect to a socket passing a token parameter, we will set the context's current_user accordingly.

Let's write a test using the socket both to listen to added categories as well as create the category that triggers the subscription.

mkdir test/absinthe/subscriptions
# test/absinthe/subscriptions/category_added_test.exs
defmodule Firestorm.Absinthe.Subscriptions.CategoryAddedTest do
  use FirestormWeb.SubscriptionCase

  @title "Some category"

  test "categoryAdded subscription", %{socket: socket} do
    subscription_query = """
      subscription {
        categoryAdded {
          id
          title
        }
      }
    """

    ref = push_doc(socket, subscription_query)

    assert_reply(ref, :ok, %{subscriptionId: _subscription_id})

    create_category_mutation = """
      mutation f {
        createCategory(title: "#{@title}") {
          title
          id
        }
      }
    """

    ref =
      push_doc(
        socket,
        create_category_mutation
      )

    assert_reply(ref, :ok, reply)
    data = reply.data["createCategory"]
    assert data["title"] == @title

    assert_push("subscription:data", push)
    data = push.result.data["categoryAdded"]
    assert data["title"] == @title
  end
end

If we try to run the test, it fails. That's because we haven't yet added the absinthe_phoenix dependency:

defmodule Firestorm.MixProject do
  # ...
  defp deps do
    [
      # ...
      {:absinthe_phoenix, "~> 1.4"},
      # ...
    ]
  end
end
mix deps.get

We also need to add Absinthe.Subscription to our supervision tree:

# lib/firestorm/application.ex
defmodule Firestorm.Application do
  # ...
  def start(_type, _args) do
    import Supervisor.Spec

    children = [
      # ...
      supervisor(Absinthe.Subscription, [FirestormWeb.Endpoint])
      # ...
    ]
    # ...
  end
  # ...
end

And set up our endpoint for subscriptions:

defmodule FirestormWeb.Endpoint do
  # ...
  use Absinthe.Phoenix.Endpoint
  # ...
end

We can run the test again, and it fails with:

No message matching %Phoenix.Socket.Reply{ref: ^ref, status: :ok, payload: %{subscriptionId: _subscription_id}} after 100ms.
Process mailbox:
  %Phoenix.Socket.Reply{join_ref: 837, payload: %{errors: [%{locations: [], message: "Operation \"subscription\" not supported"}]}, ref: #Reference<0.1369317918.1173094404.168025>, status: :error, topic: "__absinthe__:control"}
code: assert_reply(ref, :ok, %{subscriptionId: _subscription_id})
stacktrace:
  test/absinthe/subscriptions/category_added_test.exs:22: (test)

We expect this failure, as we haven't yet added any subscriptions. Let's add the categoryAdded subscription:

# lib/firestorm_web/schema.ex
defmodule FirestormWeb.Schema do
  # ...
  subscription do
    @desc "Get notified when a category is added"
    field(:category_added, non_null(:category)) do
      config(fn _, _ ->
        {:ok, topic: :global}
      end)

      trigger(:create_category, topic: fn _ -> :global end)
    end
  end
end

Now if we run the tests, they pass.

Adding a subscription for new threads in a category

Let's do the same thing for threads that are added. A slight difference - this subscription takes a categoryId so you can just listen for new threads in a given category:

defmodule Firestorm.Absinthe.Subscriptions.ThreadAddedTest do
  use FirestormWeb.SubscriptionCase

  alias FirestormData.Categories

  @category_title "Some category"
  @title "Some thread"
  @body "First post"

  setup do
    {:ok, category} = Categories.create_category(%{title: @category_title})
    {:ok, category: category}
  end

  test "threadAdded subscription", %{
    socket: socket,
    category: category,
    user: user
  } do
    subscription_query = """
      subscription {
        threadAdded(categoryId: "#{category.id}") {
          id
          title
          posts {
            body
            user {
              id
            }
          }
        }
      }
    """

    ref = push_doc(socket, subscription_query)

    assert_reply(ref, :ok, %{subscriptionId: _subscription_id})

    create_thread_mutation = """
      mutation f {
        createThread(categoryId: "#{category.id}", title: "#{@title}", body: "#{@body}") {
          title
          id
        }
      }
    """

    ref =
      push_doc(
        socket,
        create_thread_mutation
      )

    assert_reply(ref, :ok, reply)
    data = reply.data["createThread"]
    assert data["title"] == @title

    assert_push("subscription:data", push)
    data = push.result.data["threadAdded"]
    assert data["title"] == @title
    assert [first_post] = data["posts"]
    assert first_post["body"] == @body
    assert first_post["user"]["id"] == user.id
  end
end

If we run the tests, they fail because there's no message from the subscription query. That's because there's no such subscription - we'll add it:

# lib/firestorm_web/schema.ex
defmodule FirestormWeb.Schema do
  # ...
  subscription do
    # ...
    @desc "Get notified when a thread is added"
    field(:thread_added, non_null(:thread)) do
      arg(:category_id, non_null(:id))

      config(fn args, _ ->
        {:ok, topic: args.category_id}
      end)

      trigger(:create_thread, topic: fn thread -> thread.category_id end)
    end
  end
end

Super easy, the test passes now.

Adding a postAdded subscription

We'll do the same thing for posts on a thread:

defmodule Firestorm.Absinthe.Subscriptions.PostAddedTest do
  use FirestormWeb.SubscriptionCase

  alias FirestormData.{
    Categories,
    Threads
  }

  @category_title "Some category"
  @thread_title "Some thread"
  @first_post_body "First post"
  @body "Second post"

  setup %{user: user} do
    {:ok, category} = Categories.create_category(%{title: @category_title})
    {:ok, thread} = Threads.create_thread(category, user, %{title: @thread_title, body: @body})
    {:ok, category: category, thread: thread}
  end

  test "threadAdded subscription", %{
    socket: socket,
    thread: thread,
    user: user
  } do
    subscription_query = """
      subscription {
        postAdded(threadId: "#{thread.id}") {
          body
          user {
            id
          }
        }
      }
    """

    ref = push_doc(socket, subscription_query)

    assert_reply(ref, :ok, %{subscriptionId: _subscription_id})

    create_post_mutation = """
      mutation f {
        createPost(threadId: "#{thread.id}", body: "#{@body}") {
          id
          body
        }
      }
    """

    ref =
      push_doc(
        socket,
        create_post_mutation
      )

    assert_reply(ref, :ok, reply)
    data = reply.data["createPost"]
    assert data["body"] == @body

    assert_push("subscription:data", push)
    data = push.result.data["postAdded"]
    assert data["body"] == @body
    assert data["user"]["id"] == user.id
  end
end

And we'll add the subscription to the schema:

defmodule FirestormWeb.Schema do
  # ...
  subscription do
    # ...
    @desc "Get notified when a post is added"
    field(:post_added, non_null(:post)) do
      arg(:thread_id, non_null(:id))

      config(fn args, _ ->
        {:ok, topic: args.thread_id}
      end)

      trigger(:create_post, topic: fn post -> post.thread_id end)
    end
  end
end

Now we can subscribe to be notified of new categories, threads in a category, and posts in a thread!

Paginating categories

It would be ideal to paginate all of our data. In the interest of time, we'll just paginate the categories for now.

We'd like the categories field to return a paginated_categories instead of a non_null(list_of(non_null(:category))). Let's define the GraphQL type:

# lib/firestorm_web/schema/categories_types.ex
defmodule FirestormWeb.Schema.CategoriesTypes do
  # ...
  object :paginated_categories do
    field(:page, non_null(:integer)) do
      resolve(fn pagination, _, _ -> {:ok, pagination.page_number} end)
    end

    field(:per_page, non_null(:integer)) do
      resolve(fn pagination, _, _ -> {:ok, pagination.page_size} end)
    end
    field(:total_pages, non_null(:integer))
    field(:total_entries, non_null(:integer))
    field(:entries, non_null(list_of(non_null(:category))))
  end
  # ...
end

We're going to use Scrivener for pagination. Our preferred return type for pagination has slightly different fields, so we rewrite them in this type.

Then we'll change the categories field's type to return the paginated_categories type:

defmodule FirestormWeb.Schema do
  # ...
  # Momentarily we'll define the pagination input type
  import_types(FirestormWeb.Schema.PaginationTypes)
  # ...
  query do
    @desc "Get all categories"
    field(:categories, non_null(:paginated_categories)) do
      arg(:pagination, :pagination)
      resolve(&Resolvers.Categories.list_categories/3)
    end
    # ...
  end
  # ...
end

Now the category field takes an optional argument describing the pagination details. We'll define that in a PaginationTypes module:

# lib/firestorm_web/schema/pagination_types.ex
defmodule FirestormWeb.Schema.PaginationTypes do
  use Absinthe.Schema.Notation

  @desc "Pagination options"
  input_object :pagination do
    field(:per_page, non_null(:integer))
    field(:page, non_null(:integer))
  end
end

We can specify how many entries we want in each page as well as the page we'd like to see.

Let's update the categories query test:

defmodule Firestorm.Absinthe.Queries.CategoriesTest do
  # ...
  @title "Some category"

  test "getting a paginated list of categories without passing explicit pagination options returns all results" do
    query = """
    {
      categories {
        totalPages
        totalEntries
        page
        perPage
        entries {
          id
          title
        }
      }
    }
    """

    context = %{}

    {:ok, %{data: %{"categories" => categories}}} = Absinthe.run(query, Schema, context: context)

    assert [] == categories["entries"]
    assert categories["page"] == 1

    assert categories["perPage"] == 20
    assert categories["totalPages"] == 1
    assert categories["totalEntries"] == 0
  end

  test "get a paginated list of categories" do
    {:ok, category} = Categories.create_category(%{title: @title})

    query = """
    {
      categories(pagination: {page: 1, perPage: 2}) {
        totalPages
        totalEntries
        page
        perPage
        entries {
          id
          title
        }
      }
    }
    """

    context = %{}

    {:ok, %{data: %{"categories" => categories}}} = Absinthe.run(query, Schema, context: context)

    assert [first_category] = categories["entries"]
    assert first_category["title"] == @title
    assert categories["page"] == 1
    assert categories["perPage"] == 2
    assert categories["totalPages"] == 1
    assert categories["totalEntries"] == 1
  end
  # ...
end

If we run it, it fails because there's no list_categories function in the Categories resolver. We'll add it:

# lib/firestorm_web/resolvers/categories.ex
defmodule FirestormData.Categories do
  # ...
  @type index_params :: %{
          optional(:pagination) => %{
            optional(:per_page) => non_neg_integer(),
            optional(:page) => non_neg_integer()
          }
        }
  @type index :: %{
          entries: [Category.t()],
          page: non_neg_integer(),
          per_page: non_neg_integer(),
          total_pages: non_neg_integer(),
          total_entries: non_neg_integer()
        }

  @doc """
  List categories
  """
  @spec list_categories(index_params()) :: index()
  def list_categories(options) do
    pagination = Map.get(options, :pagination, %{page: 1, per_page: 20})
    page = Map.get(pagination, :page, 1)
    per_page = Map.get(pagination, :per_page, 20)

    Repo.paginate(Category, page: page, page_size: per_page)
  end
  # ...
end

Now our categories query is paginated.

Adding CORS support

One more thing - in order to support web clients on different domains, we need to enable CORS support. We'll just open the API up to everyone. We'll use the corsica package for this:

defmodule Firestorm.MixProject do
  # ...
  defp deps do
    [
      # ...
      {:corsica, "~> 1.1"},
      # ...
    ]
  end
end
mix deps.get

We'll plug it into the Endpoint:

defmodule FirestormWeb.Endpoint do
  # ...
  plug(Corsica,
    origins: "*",
    allow_headers: [
      "authorization",
      "content-type"
    ]
  )
  # ...
end

Now clients served at different domains will be able to use our API.

Summary

Today we built a Phoenix app that uses Absinthe to provide a GraphQL server. It supports authentication for both HTTP requests and socket requests. You can create users, authenticate them, and create, view, and subscribe to categories, threads, and posts. We also added CORS to allow web clients to access it.

Resources