DEV Community

Roberts Guļāns
Roberts Guļāns

Posted on • Edited on

GraphQl with absinthe: How to map data to different structures

After starting using graphql with absinthe and discovering some gotchas everything went nice and smooth. After a few days tho, I encountered a case that our data source has a different data structure than one that has to be defined in graphql. While it was mostly straight forward to implement, I didn't find many resources on how to do it.

Assertions
Works on my machine
  • elixir: 1.7.3
  • phoenix: 1.4.0
  • absinthe: 1.4.13

Bussiness rules

Existing user entity has field name, which oddly enough doesn't contain a string, but a map (saved as json in DB) with two keys (first and last). But graphql has to have first_name, second_name and their combined value as full_name to be alongside id.

Initial setup

To start to implement needed business requirements, as usual, there is some bootstrap code that is needed. This is no exception.

We will have to implement four modules:

  • MyApp.Schema -> graphql structures, queries and mutations.
  • MyApp.UsersRepository -> represents existing repository.
  • MyApp.UsersRepository.User -> represents exisitng ecto schema.
  • MyApp.UsersResolver -> graphql resolver callbacks.

Both MyApp.UsersRepository and MyApp.UsersRepository.User in this example is outside of the graphql boundary. This part of code should live in a different context, and shouldn't be influenced in any way by the fact that now it has a new consumer (graphql) that requires different data structure.

Note that MyApp.Schema defines an object user that has fields described in the business rules, but MyApp.UsersRepository expect name values to be nested under name key (representing existing repository structure).

defmodule MyApp.Schema do
  @moduledoc """
    GraphQL schema description (available objects, queries and mutations)
  """

  use Absinthe.Schema

  alias MyApp.UsersResolver

  @doc """
    User object with few scalar fields
  """
  object :user do
    field :id, :integer
    field :first_name, :string
    field :last_name, :string
    field :full_name, :string
  end
end

defmodule MyApp.UsersRepository do
  @moduledoc """
    Actual repository, used as change boundary.
    This code will not change at all during this blog post.
  """

  defmodule User do
    @moduledoc """
      In a real-life scenario, this most likely would be an ecto schema.

      For sake of example, simple struct will also do.
    """
    defstruct id: nil, name: %{"first" => nil, "last" => nil}
  end

  @doc """
    Gets a list of users from the data source.
    In real life scenario most likely from DB via ecto.

    In this case list with one hardcoded user.
  """
  def get_all() do
    [%User{id: 1, name: %{"first" => "John", "last" => "Doe"}}]
  end

  @doc """
    Creates a user in a data store.
    In real life scenario most likely to DB via ecto.

    In this case, checks if data has required structure and if so, add id (like autogenerated id value).
  """
  def create(%User{name: %{"first" => _, "last" => _}} = user), do: {:ok, %{user | id: 10}}
  def create(_), do: {:error, "Wrong structure"}
end

defmodule MyApp.UsersResolver do
  @moduledoc """
    Handles data processing for graphql.

    Sort of like repository, but defined as graphql resolve callbacks.
  """
  alias MyApp.UsersRepository
  alias MyApp.UsersRepository.User
end

Receiving data

We need to be able to make the following query:

query {
  users {
    firstName,
    lastName,
    fullName
  }
}

So we need to add query block with users field.
In MyApp.UsersResolver we make a function that is defined as a resolver for users query. The only difference from the usual case where data maps 1:1 is that we use &Enum.map/2 to iterate over results from &MyApp.UsersRepository.get_all/0 with &to_graphql/1 and can make any structure we need.

Note that graphql by default expects atom keys. It can be changed if needed.

defmodule MyApp.Schema do
  ...

  @doc """
    Makes query `users` available for API consumers. 
    The query is expected to return a list of `user` objects.
    `&UsersResolver.fetch_users/3` is responsible to make that happen
  """
  query do
    field :users, list_of(:user), resolve: &UsersResolver.fetch_users/3
  end
end

defmodule MyApp.UsersResolver do
  ...

  @doc """
    Fetches all users from the repository.
  """
  def fetch_users(_, _, _) do
    users = UsersRepository.get_all() |> Enum.map(&to_graphql/1)
    {:ok, users}
  end

  @doc """
    Map data from user instance to one required by graphql api.
  """
  defp to_graphql(%User{id: id, name: %{"first" => first, "last" => last}}) do
    %{id: id, first_name: first, last_name: last, full_name: "#{first} #{last}"}
  end
end

now executing the required query we get expected output:

{
  "data": {
    "users": [
      {
        "lastName": "Doe",
        "fullName": "John Doe",
        "firstName": "John"
      }
    ]
  }
}

Mutation mapping

This is a mutation we are required to be able to execute:

mutation {
  createUser(firstName: "Doe", lastName: "John") {
    id
    fullName
  }
}

Here things get a little bit more involved in absinthe. Not much, but still.
First of all, now we crate mutation. Then in create_user resolver, we take the second argument, as it is where arguments passed through graphql are located.
We do a similar map as previously with to_graphql but in reverse.

defmodule MyApp.Schema do
  ...

  @doc """
    Mutation to create a new user
    Two fields are required, and mutation is expected to return created user instance.
  """
  mutation do
    field :create_user, :user do
      arg :first_name, non_null(:string)
      arg :last_name, non_null(:string)

      resolve &UsersResolver.create_user/3
    end
  end
end

defmodule MyApp.UsersResolver do
  ...

  @doc """
    Creates a new user to repository.
  """
  def create_user(_, args, _) do
    args |> from_graphql |> UsersRepository.create()
  end

  @doc """
    Converts graphql structure to one required by the repository.
  """
  defp from_graphql(%{first_name: first, last_name: last}) do
    %User{name: %{"first" => first, "last" => last}}
  end
end

And we expect to get as a response:

{
  "data": {
    "createUser": {
      "id": 10,
      "fullName": "Doe John"
    }
  }
}

but that isn't what we actually get. fullName is null. Reason for it is that &MyApp.UsersResolver. create_user/3 resolver callback returns User struct (received from MyApp.UsersRepository.create/1). The resolver should always return graphql valid structure. To fix it, we need to map User struct value back to graphql with previously used &to_graphql/1 like so:

defmodule MyApp.UsersResolver do
  ...

  def create_user(_, args, _) do
    args 
    |> from_graphql 
    |> UsersRepository.create()
    |> case do
      {:ok, user} -> {:ok, to_graphql(user)}
      error -> error
    end
  end
end

Now required mutation actually returns what was expected previously.

In conclusion

  • graphql doesn't interfere with data resolving. Resolve callbacks are plain elixir code, you can manipulate data as ever you please. Maps expect to have atom keys by default, but it can be changed.
  • Creating MyApp.UsersResolver in between MyApp.Schema and MyApp.UsersRepository is a great way to split models into sperate use cases. Each model deals only with one specific task.
  • resolvers for mutations, that return object, should take in consideration, that mutation callback has to return object in graphql valid structure (mutate return data if needed).
  • mutation callback is the only place that does two jobs, saving data and preparing a response. If we applied something CQSish that mutation at most would return the newly generated id, so no response mapping would be needed in that case.

I'm still pretty new to graphql, but I really like what I see.

P.S. If you have any feedback, suggestion, question or thoughts about this topic, please let me know :)

P.P.S Whole code without comments

defmodule MyApp.Schema do
  use Absinthe.Schema

  alias MyApp.UsersResolver

  object :user do
    field :id, :integer
    field :first_name, :string
    field :last_name, :string
    field :full_name, :string
  end

  query do
    field :users, list_of(:user), resolve: &UsersResolver.fetch_users/3
  end

  mutation do
    field :create_user, :user do
      arg :first_name, non_null(:string)
      arg :last_name, non_null(:string)

      resolve &UsersResolver.create_user/3
    end
  end
end

defmodule MyApp.UsersRepository do
  defmodule User do
    defstruct id: nil, name: %{"first" => nil, "last" => nil}
  end

  def get_all() do
    [%User{id: 1, name: %{"first" => "John", "last" => "Doe"}}]
  end

  def create(%User{name: %{"first" => _, "last" => _}} = user), do: {:ok, %{user | id: 10}}
  def create(_), do: {:error, "Wrong structure"}
end

defmodule MyApp.UsersResolver do
  alias MyApp.UsersRepository
  alias MyApp.UsersRepository.User

  def fetch_users(_, _, _) do
    users = UsersRepository.get_all() |> Enum.map(&to_graphql/1)
    {:ok, users}
  end

  def create_user(_, args, _) do
    args
    |> from_graphql
    |> UsersRepository.create()
    |> case do
      {:ok, user} -> {:ok, to_graphql(user)}
      error -> error
    end
  end

  defp to_graphql(%User{id: id, name: %{"first" => first, "last" => last}}) do
    %{id: id, first_name: first, last_name: last, full_name: "#{first} #{last}"}
  end

  defp from_graphql(%{first_name: first, last_name: last}) do
    %User{name: %{"first" => first, "last" => last}}
  end
end

Top comments (0)