Using cqrs_tools with Absinthe
Rationale
A common pain point when writing APIs is the seemingly unnecessary duplication of code. This is true for GraphQL and REST APIs.
I’ve seen countless requests of people wanting to code gen Absinthe objects from Ecto schemas and many other sources. Of course that’s possible to do. But I feel that Ecto schemas, that are mapped to database tables, are the wrong candidates for code generation. You don’t want to tie your APIs to implementation details. What you really want to expose are your domain layer’s operations.
Of course, with that realization, you still end up asking “How do I stop code duplication?”.
The macros in cqrs_tools
define everything you need to expose your domain as GraphQL operations.
The benefits of this is that you really only have to focus on your domain layer and it’s rules. cqrs_tools
can interpret those rules and Absinthe will inherit all of your work.
If you’re already familiar with cqrs_tools, you can skip down to the Absinthe specific section: Absinthe Arrives
.
Setup
Install Dependencies, Create a Read Model and a Repo
# Turn off automatic jason encoder implementations for this demo
Application.put_env(:cqrs_tools, :create_jason_encoders, false)
Application.put_env(:cqrs_tools, :absinthe_relay, repo: Repo)
Mix.install([
{:absinthe, "~> 1.6"},
{:absinthe_relay, "~> 1.5"},
{:cqrs_tools, "~> 0.3"},
{:ecto, "~> 3.6"},
{:etso, "~> 0.1.5"},
{:jason, "~> 1.2"},
{:elixir_uuid, "~> 1.6", override: true, hex: :uuid_utils}
])
Define a Read Model
defmodule User do
use Ecto.Schema
import Ecto.Changeset
@primary_key false
schema "users" do
field :id, :binary_id, primary_key: true
field :name, :string
field :email, :string
end
def changeset(user \\ %__MODULE__{}, attrs) do
user
|> cast(attrs, [:id, :name, :email])
|> validate_required([:id, :name, :email])
end
end
Define an Ecto Repo
defmodule Repo do
use Ecto.Repo, otp_app: :my_app, adapter: Etso.Adapter
end
Repo.start_link([])
Define a Command
defmodule CreateUser do
use Cqrs.Command
field :name, :string
field :email, :string
field :id, :binary_id, internal: true
@impl true
def handle_validate(command, _opts) do
Ecto.Changeset.validate_format(command, :email, ~r/@/)
end
@impl true
def after_validate(%{email: email} = command) do
Map.put(command, :id, UUID.uuid5(:oid, email))
end
@impl true
def before_dispatch(%{email: email} = command, _opts) do
if Users.get_user(%{email: email}, exists?: true),
do: {:error, :user_exists},
else: {:ok, command}
end
@impl true
def handle_dispatch(command, _opts) do
command
|> Map.from_struct()
|> User.changeset()
|> Repo.insert!()
end
end
Define a Query
defmodule GetUser do
use Cqrs.Query
filter :id, :binary_id
filter :email, :string
option :exists?, :boolean, default: false
@impl true
def handle_create(filters, _opts) do
query = from(u in User)
Enum.reduce(filters, query, fn
{:id, id}, query -> from q in query, where: q.id == ^id
{:email, email}, query -> from q in query, where: q.email == ^email
end)
end
@impl true
def handle_execute(query, opts) do
if Keyword.fetch!(opts, :exists?),
do: Repo.exists?(query, opts),
else: Repo.one(query, opts)
end
end
Expose the query
This another convenience macro. It is used by the CreateUser
command is before_dispatch
defmodule Users do
use Cqrs.BoundedContext
query GetUser
end
Absinthe Arrives
Create Absinthe Schema
Here instead of using Absinthe.Schema.Notation
, we will use
Cqrs.Absinthe
. This will import the derive_query
, derive_mutation
,
and derive_mutation_input
macros.
defmodule UserTypes do
use Cqrs.Absinthe
use Absinthe.Schema.Notation
object :user do
field :id, :id
field :name, :string
field :email, :string
end
object :user_mutations do
derive_mutation CreateUser, :user
end
object :user_queries do
derive_query GetUser, :user
end
end
defmodule Schema do
use Absinthe.Schema
import_types UserTypes
query do
import_fields :user_queries
end
mutation do
import_fields :user_mutations
end
end
Test the query.
document = """
query user($id: ID!){
getUser(id: $id){
id
name
email
}
}
"""
Absinthe.run(document, Schema, variables: %{"id" => "052c1984-74c9-522f-858f-f04f1d4cc786"})
First we need to create a user
document = """
mutation create_user($name: String!, $email: String!){
createUser(name: $name, email: $email){
id
name
email
}
}
"""
Absinthe.run(document, Schema, variables: %{"name" => "chris", "email" => "chris@example.com"})
Now if you run the query again, the user will be returned.
document = """
query user($id: ID!){
getUser(id: $id){
id
name
email
}
}
"""
Absinthe.run(document, Schema, variables: %{"id" => "052c1984-74c9-522f-858f-f04f1d4cc786"})
Use with Relay Style Pagination
You can support relay style cursor pagination with the derive_connection
macro
The only prerequisites are that you have :absinthe_relay
declared as a dependecy and you’ve defined the connection for the node type that you are returning.
Let’s define a new query and recompile our schema modules.
defmodule ListUsers do
use Cqrs.Query
filter :email, :string
filter :name, :string
@impl true
def handle_create(filters, _opts) do
query = from(u in User)
Enum.reduce(filters, query, fn
{:email, email}, query -> from(q in query, where: q.email == ^email)
{:name, name}, query -> from(q in query, where: q.name == ^name)
end)
end
@impl true
def handle_execute(query, opts), do: Repo.all(query, opts)
end
defmodule UserTypes do
use Cqrs.Absinthe
use Cqrs.Absinthe.Relay
use Absinthe.Schema.Notation
use Absinthe.Relay.Schema.Notation, :modern
object :user do
field :id, :id
field :name, :string
field :email, :string
end
connection(node_type: :user)
object :user_mutations do
derive_mutation CreateUser, :user
end
object :user_queries do
derive_query GetUser, :user
# We're setting repo here for illustration. Normally it would be configured in `config.exs`. See above in `Install Dependencies`
derive_connection ListUsers, :user, as: :users, repo: Repo
end
end
defmodule Schema do
use Absinthe.Schema
use Absinthe.Relay.Schema, :modern
import_types UserTypes
query do
import_fields :user_queries
end
mutation do
import_fields :user_mutations
end
end
Go ahead and create a few users.
document = """
mutation create_user($name: String!, $email: String!){
createUser(name: $name, email: $email){
id
name
email
}
}
"""
Absinthe.run(document, Schema, variables: %{"name" => "hailey", "email" => "hailey@example.com"})
The new users
query is live and supports all the filters you defined!
document = """
query list_users($name: String, $email: String) {
users(first: 5, name: $name, email: $email) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
email
name
}
}
}
}
"""
# Play around with the variables here.
Absinthe.run(document, Schema, variables: %{"name" => "chris", "email" => nil})