Tutorial

Getting started with GraphQL and Absinthe in Phoenix

This post was updated 27 Mar

absinthe graphql api phoenix

In the last tutorial, I had an app with a simple REST API that was authenticated with Guardian and JSON Web Tokens. In this tutorial, I will also add the same functionality in Phoenix using GraphQL with the Absinthe library.

STEP 1 - Installation

Add Absinthe and Absinthe Plug to the mix file:

# mix.exs
defp deps do
[
{:absinthe, "~> 1.7.8"},
{:absinthe_plug, "~> 1.5.8"},
]
end

And run mix deps.get

mix deps.get

Next is to add routes for both the GraphQL schema and GraphiQL, the visual query helper and documentation tool for the API.

# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
# ...rest of routes
pipeline :graphql do
# Will be used later
end
scope "/api" do
pipe_through :graphql
forward "/", Absinthe.Plug, schema: MyAppWeb.Schema
end
if Mix.env == :dev do
forward "/graphiql", Absinthe.Plug.GraphiQL, schema: MyAppWeb.Schema
end
end

Note that the Absinthe Plug we are adding is pointing to a Schema file that I also need to create before I can start the server.

Also note that the GraphiQL interface is only accessible in dev.

STEP 2 - Getting to the first query

As I mentioned above, we already have an existing JSON endpoint and a database storage for products. So what I want to do is just hook up with that.

Remember that in the router, I specified a specific schema. It will be a single schema file where I build the entire API.

Create the file:

# lib/my_app_web/schema.ex
defmodule MyAppWeb.Schema do
use Absinthe.Schema
alias MyAppWeb.Schema
import_types(Schema.ProductTypes)
query do
import_fields(:get_products)
end
end

Note that I import ProductTypes. That is the module that will specify and handle calling the product part of the API. I need to add the file:

# lib/my_app_web/schema/product_types.ex
defmodule MyAppWeb.Schema.ProductTypes do
use Absinthe.Schema.Notation
alias MyAppWeb.Resolvers
@desc "A product"
object :product do
field :id, :id
field :name, :string
field :description, :string
field :price, :float
end
object :get_products do
@desc """
Get a list of products
"""
field :products, list_of(:product) do
resolve(&Resolvers.Products.list_products/2)
end
end
end

Note that in the code above, I refer to Resolvers.Products.list_products. Resolvers will be the modules that connect to the products context. The products resolver looks like this:

# lib/my_app_web/resolvers/products.ex
defmodule MyAppWeb.Resolvers.Products do
alias MyApp.Products
def list_products(_args, _context) do
{:ok, Products.list_products()}
end
end

That should be enough to have something going. I can start the server and go to the visual interface:

http://localhost:4000/graphiql

In the query interface, I can in the bottom left write:

{
products {
id
name
price
description
}
}

And run the query. If everything works, I should get a result:

STEP 3 - User creation and authentication

Since I already have Guardian installed in the app I will just utilize that. However, I need to create a specific plug that works with Absinthe. What it does is that it can take a resource from the JSON Web Token and add it to Absinthe’s “context”. That is useful to see if a user is authorized. The plug looks like:

# lib/my_app_web/plugs/context.ex
defmodule MyAppWeb.Context do
@behaviour Plug
import Plug.Conn
def init(opts), do: opts
def call(conn, _) do
case build_context(conn) do
{:ok, context} ->
put_private(conn, :absinthe, %{context: context})
_ ->
conn
end
end
defp build_context(conn) do
with ["" <> token] <- get_req_header(conn, "authorization"),
{:ok, user, _claims} <- MyApp.Guardian.resource_from_token(token) do
{:ok, %{current_user: user}}
end
end
end

I add it in the router in the new graphql pipeline that I already set up.

# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
# ...rest of routes
pipeline :graphql do
plug MyAppWeb.Context
end
# ...rest of routes
end

I already made a product_types so now I also need the user_types. I want a user to both be able to register and login.

# lib/my_app_web/schema/user_types.ex
defmodule MyAppWeb.Schema.UserTypes do
use Absinthe.Schema.Notation
alias MyAppWeb.Resolvers
@desc "A user"
object :user do
field :email, :string
field :id, :id
end
object :create_user_mutation do
@desc "Create a user"
field :create_user, :user do
arg(:email, non_null(:string))
arg(:password, non_null(:string))
resolve(&Resolvers.Accounts.create_user/3)
end
end
object :login_mutation do
@desc "Login with the params"
field :create_session, :session do
arg(:email, non_null(:string))
arg(:password, non_null(:string))
resolve(&Resolvers.Accounts.login/2)
end
end
@desc "session value"
object :session do
field(:token, :string)
field(:user, :user)
end
end

And the resolver for the user login and creation.

# lib/my_app_web/resolvers/accounts.ex
defmodule MyAppWeb.Resolvers.Accounts do
alias MyApp.Accounts
alias MyApp.Accounts.User
def create_user(_parent, args, _context) do
Accounts.register_user(args)
end
def login(%{email: email, password: password}, _info) do
with %User{} = user <- Accounts.get_user_by_email_and_password(email, password),
{:ok, jwt, _full_claims} <- MyApp.Guardian.encode_and_sign(user) do
{:ok, %{token: jwt}}
else
_ -> {:error, "Incorrect email or password"}
end
end
end

Note that I call the resolver Accounts. That is because I started the app with Phx Gen Auth and that generator created an Accounts context for handling user signups and logins. I call the resolver the same so they map up better.

I also need to import the user_types in the schema. In GraphQL world, whenever you do a create or update, that is called mutations. So I will add that in a mutations block:

# lib/my_app_web/schema.ex
defmodule MyAppWeb.Schema do
use Absinthe.Schema
alias MyAppWeb.Schema
import_types(Schema.UserTypes)
import_types(Schema.ProductTypes)
query do
import_fields(:get_products)
end
mutation do
import_fields(:login_mutation)
import_fields(:create_user_mutation)
end
end

Now I have enough for testing the user login with my existing user (set up in the previous tutorial). I will once again go to the graphical interface.

First I will start with wrong credentials:

And get the error message. But with the correct credentials:

So far, so good. Now when I know I can get a valid JSON Web Token, I can move forward.

STEP 4 - Adding the products CRUD

Now its time to add the products CRUD in GraphQL and I want to authenticate the mutations with Guardian and JSON Web Tokens. So, to be clear, I want anybody to be able to see/query for the products, but only signed in users to be able to create, update and delete. In this step, I will focus on creating.

I need to extend the products resolver with a create_product function:

# lib/my_app_web/resolvers/products.ex
defmodule MyAppWeb.Resolvers.Products do
alias MyApp.Products
alias MyApp.Products.Product
def list_products(_args, _context) do
{:ok, Products.list_products()}
end
def create_product(args, %{context: %{current_user: _user}}) do
case Products.create_product(args) do
{:ok, %Product{} = product} -> {:ok, product}
{:error, changeset} -> {:error, inspect(changeset.errors)}
end
end
def create_product(_args, _context), do: {:error, "Not Authorized"}
end

Note the pattern matching going on for figuring out if a user is authenticated. The context part comes from the Context plug I added earlier. The idea is that if there is no %{current_user: _user} then the user is not authenticated.

In the schema, I also need to add the mutation:

# lib/my_app_web/schema.ex
defmodule MyAppWeb.Schema do
# ... rest of the code
mutation do
import_fields(:login_mutation)
import_fields(:create_user_mutation)
import_fields(:create_product)
end
end

Now I can go to the query interface and try to create a product:

mutation (
$name: String!,
$description: String!,
$price: Float!,
) {
product: createProduct(
name: $name,
description: $description,
price: $price
) {
id
name
description
price
}
}
# Variables
{
"name": "Awesome Product",
"description": "Some longer description",
"price": 200.50
}

However, without providing a valid JSON Web Token, I get an error/unauthorized message:

So, what I need to do is to once again do a create session mutation.

mutation($email: String!, $password: String!) {
session: createSession(email: $email, password: $password) {
token
}
}
# Variables
{
"email": "andreas@example.com",
"password": "supersecret123"
}

And with the token I get back, I can add it as a request header:

Note that I need to add the “bearer: “ before the token.

Now, everything should be good and I can see that the product was created.

STEP 5 - Full product CRUD

For the full products CRUD I need to add a query for getting a single product by id, update a product and delete a product.

The full schema file looks like:

defmodule MyAppWeb.Schema do
use Absinthe.Schema
alias MyAppWeb.Schema
import_types(Schema.UserTypes)
import_types(Schema.ProductTypes)
query do
import_fields(:get_products)
import_fields(:get_product)
end
mutation do
import_fields(:login_mutation)
import_fields(:create_user_mutation)
import_fields(:create_product)
import_fields(:update_product)
import_fields(:delete_product)
end
end

I also need to add the extended logic in the ProductTypes schema module:

defmodule MyAppWeb.Schema.ProductTypes do
use Absinthe.Schema.Notation
alias MyAppWeb.Resolvers
@desc "A product"
object :product do
field :id, :id
field :name, :string
field :description, :string
field :price, :float
end
object :get_products do
@desc """
Get a list of products
"""
field :products, list_of(:product) do
resolve(&Resolvers.Products.list_products/2)
end
end
object :get_product do
@desc """
Get a specific product
"""
field :product, :product do
arg(:id, non_null(:id))
resolve(&Resolvers.Products.get_product/2)
end
end
object :create_product do
@desc "Create a product"
field :create_product, :product do
arg :name, non_null(:string)
arg :description, non_null(:string)
arg :price, non_null(:float)
resolve(&Resolvers.Products.create_product/2)
end
end
object :update_product do
@desc "Update a product"
field :update_product, :product do
arg(:id, non_null(:id))
arg(:name, :string)
resolve(&Resolvers.Products.update_product/2)
end
end
end

Finally, I need to add more functions to the products resolver. The final resolver will look like:

# lib/my_app_web/resolvers/products.ex
defmodule MyAppWeb.Resolvers.Products do
alias MyApp.Products
alias MyApp.Products.Product
def list_products(_args, _context) do
{:ok, Products.list_products()}
end
def get_product(%{id: id}, _context) do
{:ok, Products.get_product(id)}
end
def create_product(args, %{context: %{current_user: _user}}) do
case Products.create_product(args) do
{:ok, %Product{} = product} -> {:ok, product}
{:error, changeset} -> {:error, inspect(changeset.errors)}
end
end
def create_product(_args, _context), do: {:error, "Not Authorized"}
def update_product(%{id: id} = params, %{context: %{current_user: _user}}) do
case Products.get_product(id) do
nil ->
{:error, "Product not found"}
%Product{} = product ->
case Products.update_product(product, params) do
{:ok, %Product{} = product} -> {:ok, product}
{:error, changeset} -> {:error, inspect(changeset.errors)}
end
end
end
def update_product(_args, _context), do: {:error, "Not Authorized"}
def delete_product(%{id: id}, %{context: %{current_user: _user}}) do
case Products.get_product(id) do
nil -> {:error, "Product not found"}
%Product{} = product -> Products.delete_product(product)
end
end
def delete_product(_args, _context), do: {:error, "Not Authorized"}
end

Conclusion

Even though the code seems quite verbose, you need to keep in mind that it’s self-documenting. The GraphiQL interface will tell an API consumer what fields are available, required, their type, and so on.

In the next tutorial, I am planning to go through testing. I basically test this with request tests. But it requires a little setup with Guardian.

Related Tutorials

Published 26 Jan - 2020
Updated 27 Mar

Create Swagger compatible custom Phoenix JSON generator

I am in the process of creating an API in Phoenix and I want it to support Swagger documentation without me having to do much. I dont want to go in ..

Published 11 Apr

Rendering an Activity Feed in Phoenix LiveView

In the previous tutorial, we built an automatic activity tracking system that records events whenever a tracked schema is inserted, updated, or dele..