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 ..

NEW
Published 27 Mar

Adding Modals to Phoenix 1.8 with DaisyUI

In Phoenix 1.8, the built-in modal component was removed. Instead, Phoenix now encourages developers to use separate LiveView pages for new and edit..