gdub01

gdub01

Absinthe graphql: testing and displaying changeset errors

I’m not sure if this is the correct way to test an API call in with Absinthe. The code works, but I’m not sure if this is what I should be doing for each query?

  @account %{email: "hey@you.com", password: "herewego"}

  setup do
    {:ok, account} = AccountResolver.create(@account, %{})
    {:ok, token} = AccountResolver.login(@account, %{})
    {:ok, %{token: token.token, id: account.id}}
  end

  test "Logged in user should be able to see their email", info do

    queryDoc = %{
      "operationName" => "account",
      "query" => "query account { account (id: #{info.id}) { email } }",
      "variables" => "{}"
    }

    conn = info.conn
      |> put_req_header("authorization", "Bearer #{info.token}")
      |> Map.put(:host, "localhost:4001")
      |> Map.put(:body_params, queryDoc)
      |> post("/api")

    assert conn.state == :sent
    assert conn.status == 200
    assert String.contains?(conn.resp_body, "hey@you.com")
  end

And in a second, not related question, I am transforming changeset errors into a string to be passed back to Absinthe on error:

  defmodule Graphqlapi.ChangesetErrors do

  def handle_changeset_errors(errors) do
    Enum.map(errors, fn {field, detail} ->
      "#{field} " <> render_detail(detail)
    end)
      |> Enum.join
  end

  def render_detail({message, values}) do
    Enum.reduce values, message, fn {k, v}, acc ->
      String.replace(acc, "%{#{k}}", to_string(v))
    end
  end

  def render_detail(message) do
    message
  end
end

So that in my graphql resolver I have this:

  def create(params, _info) do
    case Auth.register(params) do
      {:ok, account} -> {:ok, account}
      {:error, changeset} -> {:error, ChangesetErrors.handle_changeset_errors(changeset.errors)}
    end
  end

With the idea being I don’t have to write error code twice. Is that a good plan or a bad one?

Marked As Solved

benwilson512

benwilson512

Author of Craft GraphQL APIs in Elixir with Absinthe

Hey there, I’m glad you’re using this library!

Let’s take each of your questions in turn.

Testing

The overall approach here is a very common way to do integration level testing. You may also find that the functions you build to handle the resolvers need unit testing if they’re more complex, but often complexity there gets extracted into service type functions that you’d want to unit test anyway.

A couple of things however would make the testing code you have a bit more idiomatic. Most of these things are phoenix conn test related, there isn’t much absinthe specific about this method of testing.

  • use |> post("/api", query_doc) instead of directly setting :body_params
  • parse the response body into JSON to look for the value you want instead of using String.contains?. This is particularly important because GraphQL does not use HTTP status codes to indicate errors that may happen on a given field. So for example suppose your account field returned an error "No account found for email hey@you.com". Your tests would actually pass right now, but clearly there’d be an error.

There’s also a few minor things about the testing here that are a bit confusing. Where does the conn part of info.conn come from? What content type header is being set? Why is the :host value being set? Are you using phoenix or just bare plug.

Error handling

It’s definitely common to want to handle changeset errors in a generic way, and definitely noto something you want to have to call explicitly in your resolvers over and over again.

The best solution at the moment is to build a wrapper function that handles this possible return value from the resolver function its wrapping. Here’s an example:

# in your field
resolve handle_errors(&SomeResolver.function/2)

# in your schema module somewhere, or imported thereinto
def handle_errors(fun) do
  fn source, args, info ->
    case Absinthe.Resolution.call(fun, source, args, info) do
      {:error, %Ecto.Changeset{} = changeset} -> format_changeset(changeset)
      val -> val
    end
  end
end

Now all that you have to do is wrap resolvers where you want changesets to be handled in a handle_errors call and you’re good to go. Having to still manually place handle_errors throughout your schema is a bit of an annoyance as well, and so we’re working to finalize a middleware pattern that will let you apply this pattern in an even more generic way. Until then, wrapper functions are the way to go.

11
Post #2

Also Liked

gdub01

gdub01

Okay - I’ve revised the test. Here’s what it looks like now if anyone is interested. Thanks again @benwilson512

  @account %{email: "hey@you.com", password: "herewego"}

  setup do
    {:ok, account} = AccountResolver.create(@account, %{})
    {:ok, token} = AccountResolver.login(@account, %{})

    conn = build_conn()
        |> put_req_header("authorization", "Bearer #{token.token}")
        |> put_req_header("content-type", "application/json")

    {:ok, %{conn: conn, id: account.id}}
  end

  defp query_skeleton(query, query_name) do
    %{
      "operationName" => "#{query_name}",
      "query" => "query #{query_name} #{query}",
      "variables" => "{}"
    }
  end

  test "Logged in user should be able to see their email", context do
    query = """
      {
        account (id: #{context.id}) {
          email
        }
      }
    """

    res = context.conn
        |> post("/api", query_skeleton(query, "account"))

    assert json_response(res, 200)["data"]["account"]["email"] == "hey@you.com"
  end

  test "Logged in user should not be able to see their password", context do
    query = """
      {
        account (id: #{context.id}) {
          password
        }
      }
    """

    res = context.conn
        |> post("/api", query_skeleton(query, "account"))

    assert hd(json_response(res, 400)["errors"])["message"]
      == "Cannot query field \"password\" on type \"Account\"."
  end
tosbourn

tosbourn

I just wanted to say thanks to @gdub01 for asking about this and the answers that came from it. I am incredibly new to Elixir, GraphQL, and Absinthe and this helped a lot.

I’ve written up what I did in order to test both queries and mutations. Testing Absinthe with ExUnit.

The main difference is that the query param you send needs to look slightly different, these are the two helpers I came up with based on what was shared here before;

def mutation_skeleton(query) do
  %{
    "operationName" => "",
    "query" => "#{query}",
    "variables" => ""
  }
end

def query_skeleton(query, query_name) do
  %{
    "operationName" => "#{query_name}",
    "query" => "query #{query_name} #{query}",
    "variables" => "{}"
  }
end
cultofmetatron

cultofmetatron

this solution worked well for me based on brian’s answer

  def handle_errors(fun) do
    fn source, args, info ->
      case Absinthe.Resolution.call(fun, source, args, info) do
        {:error, %Ecto.Changeset{} = changeset} -> format_changeset(changeset)
        val -> val
      end
    end
  end

  def format_changeset(changeset) do
    #{:error, [email: {"has already been taken", []}]}
    errors = changeset.errors
      |> Enum.map(fn({key, {value, context}}) -> 
           [message: "#{key} #{value}", details: context]
         end)
    {:error, errors}
  end

Where Next?

Popular in Questions Top

aadeshere1
I have a another noob question about loop. Since elixir is immutable, while loop is not directly possible. total = 10 while total != 0 ...
New
minhajuddin
I have seen a lot of code which picks the first element from a list using Enum.at(0) instead of List.first. Is there a reason why people ...
New
nobody
How to bind a phoenix app to a specific ip address? could not find anything about that, nowhere, unfortunately, but for me this is quite...
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
hariharasudhan94
lets say i have a sample like a = 20; b = 10; if (a &gt; b) do {:ok, "a"} end if (a &lt; b) do {:ok, b} end if (a == b) do {:ok, "equa...
New
stefanluptak
Hello everybody, usually, I use a 29" ultra-wide monitor for VSCode which can easily accomodate explorer (files panel) + file with code ...
New
RisingFromAshes
I’ve read in another post that it may be possible with a router helper - but I couldn’t find an appropriate one, and tbh, I’m still just ...
New
JDanielMartinez
Hi! May someone helps me, please! I have two apps into an umbrella project: the first one is Database, which manages queries, and the se...
New
srinivasu
How to handle excepions in elixir? Suppose i have A, B, C ,D, E modules. and each module has get() function. A.get() method will call t...
New
chensan
I have a User schema with a :from_id field set to type :string: defmodule TweetBot.Repo.Migrations.CreateUsers do use Ecto.Migration ...
New

Other popular topics Top

electic
Hi, I am new to Elixir. I am trying to use the DateTime component to insert a date into MySQL however the there seems to be no way to fo...
New
Fl4m3Ph03n1x
About me? ( if you have nothing better to do than reading about some random guy in the internet :stuck_out_tongue: ) Hello all, this is ...
New
josevalim
Hi everyone, One of the features added to Elixir early on to help integration with Erlang code was the idea of overridable function defi...
New
alice
Hey, Just curious what are the main benefits of Elixir compared to Clojure? When is Elixir more useful than Clojure and vice versa? Th...
New
SoCreat
i’m a new one to elixir which editor can i use vs code? or atom? Thanks! :smiley:
New
gausby
I asked this very same question on twitter and got some interesting feedback, but I thought it would be a good question to ask here as we...
1207 39297 209
New
KronicDeth
Elixir plugin for JetBrain’s IntelliJ Platform (including Rubymine) This is a plugin that adds support for Elixir to JetBrains IntelliJ...
289 36128 110
New
joaquinalcerro
Hi there, I am working with Ecto-Postgresql and I need to call all of the records from a specific table but the table has 40,000 records...
New
marick
I had some trouble figuring out how to make many-to-many associations work. Once I got it working, I wrote a blog post. Because I’m a nov...
New
PeterCarter
There are pre-rolled solutions for other frameworks that do work. However, Phoenix does not seem to have these. Have people had good expe...
New

We're in Beta

About us Mission Statement