Tutorial
Getting started with GraphQL and Absinthe in Phoenix
This post was updated 27 Mar
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.