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
- you are familiar with elixir. Where to learn?
- you are familiar with both graphql and absinthe.
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 betweenMyApp.Schema
andMyApp.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)