Wire gqlgen to ent
Table of Contents
At this point we should have the scaffolding for the CLI parts of our app, the GraphQL server, as well as the database
layer using ent. With all of this in place we can start to wire the gqlgen
parts into the ent
parts of our app to
simplify the development experience.
In the last section, we passed a context.Context
which contains the *ent.Client
into the NewServer()
method for
our GraphQL server. Now we need to “extract” the *ent.Client
from the context.Context
and store it in our resolver.
To make this change, update the gql/resolver.go
to look like:
package gql
import (
"context"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/nikkomiu/gentql/ent"
)
//go:generate go run github.com/99designs/gqlgen generate
type Resolver struct {
ent *ent.Client
}
func NewResolver(entClient *ent.Client) Config {
return Config{
Resolvers: &Resolver{
ent: entClient,
},
}
}
func NewServer(ctx context.Context) *handler.Server {
resolver := NewResolver(ent.FromContext(ctx))
return handler.NewDefaultServer(NewExecutableSchema(resolver))
}
I’ve split up some code into multiple lines, but otherwise we are updating the NewResolver()
method to take the
*ent.Client
in as a parameter and set it on the Resolver{}
to the new property.
With that in place loading the *ent.Client
is easy by just calling the FromContext(context.Context)
method within the ent package that was generated.
You may be wondering why we have a NewResolver()
and a NewServer()
method where both are exported. You may also be
wondering why we pass the context.Context
into the NewServer()
but not NewResolver()
. In both of these cases, we
are doing it this way for testing.
When we write tests for the GraphQL Resolvers, we will need to use the NewResolver()
method so we can call the
resolver methods. It’s also easier for testing if we pass the *ent.Client
instead of the context.Context
since it’s
less boilerplate in testing.
Now that we have ent wired into our GraphQL resolver, we can create our Note schema for GraphQL. To do so, let’s create
the gql/schema/note.graphql
with the following:
type Note {
id: Int!
nodeId: ID!
title: String!
bodyMarkdown: String!
bodyHtml: String!
createdAt: Time!
updatedAt: Time!
}
input NoteInput {
title: String!
body: String!
}
extend type Query {
notes: [Note!]!
}
extend type Mutation {
createNote(input: NoteInput!): Note!
updateNote(id: Int!, input: NoteInput!): Note!
deleteNote(id: Int!): Boolean!
}
In this schema file, we are creating two models. The first one type Note
is the type that contains our Note for
GraphQL responses. The input NoteInput
model is used for requests to create or update a Note in the application.
We have also added four methods:
- List Notes Query -
notes: [Note!]!
- Create -
createNote(input: NoteInput!): Note!
- Update -
updateNote(nodeId: ID!, input: NoteInput!): Note!
- Delete -
deleteNote(nodeId: ID!): Boolean!
With this, we will be generating our CRUD endpoints to create, read, update, and delete our Notes. However, you may
notice that we currently don’t have a way to get a single Note instance, and we can only list them. This will be
addressed in a little bit when we add the node(nodeId: ID!): Node
Query method.
Since we are using Time
for the createdAt
and updatedAt
fields, we need to also add the Time
type. Since this is
a common type I’m going to put it at the top of gql/schema/common.graphql
:
scalar Time
# ...
Now that we have updated the GraphQL schema files, we can regenerate the methods for GraphQL:
go generate ./...
As long as it works, you should now have a new gql/note.go
file with a bunch of methods that we need to fill
out. First up, we’re going to look at this method:
func (r *queryResolver) Notes(ctx context.Context) ([]*model.Note, error) {
panic(fmt.Errorf("not implemented: Notes - notes"))
}
The Notes(context.Context) ([]*model.Note, error)
method allows us to list Notes. If you notice the return type for
this currently is under the gql/model
package and isn’t referencing our ent
Note model. It would be nice if we could
tell gqlgen
to automatically use the ent
models instead of creating new ones when possible. That’s what we’re going
to fix next.
To allow binding ent
models to gqlgen
, we need to add the following to the gqlgen.yml
file at the root of the
project:
autobind:
- github.com/nikkomiu/gentql/ent
This change will tell gqlgen
to use all models within ent
instead of generating a new model where possible.
Regenerate your code:
go generate ./...
If all goes well, you should now see that the Notes(context.Context) ([]*ent.Note, error)
method now wants an ent
model in the return instead of a gqlgen
model now. With that, we can update this to resolve all by changing the
implementation to:
func (r *queryResolver) Notes(ctx context.Context) ([]*ent.Note, error) {
return r.ent.Note.Query().All(ctx)
}
So far we haven’t been able to see our app working end-to-end with GraphQL and ent. Let’s change that now by
implementing the CreateNote()
resolver:
func (r *mutationResolver) CreateNote(ctx context.Context, input model.NoteInput) (*ent.Note, error) {
return r.ent.Note.Create().
SetTitle(input.Title).
SetBody(input.Body).
Save(ctx)
}
Luckily for us, ent
has a lot of nice features built into it that match up with gqlgen
really well. So we can just
set the fields we want on our new Note, save it, and return it directly.
Let’s test out what we have at this point by creating a new Note. In our GraphQL browser window, run the following:
mutation {
createNote(
input: { title: "Example", body: "Some [link](https://google.com)!" }
) {
id
title
}
}
You should get back a response like:
{
"data": {
"createNote": {
"id": 1,
"title": "Example"
}
}
}
If you notice in the Note
type of the GraphQL schema file (gql/schema/note.graphql
), we have two fields for the
body
field that is stored in ent but neither of them match the ent field. This helps us to split the application model
from the database model and allows us to hide database fields from the API as well as adding additional fields that can
be resolved from the database model. Both of these we will see using the body
field example.
Right now the two body fields are set to panic. Let’s test this out by running the following query against our GraphQL endpoint:
query {
notes {
id
title
bodyHtml
bodyMarkdown
}
}
You should get back a response like:
{
"errors": [
{
"message": "internal system error",
"path": ["notes", 0, "bodyHtml"]
},
{
"message": "internal system error",
"path": ["notes", 0, "bodyMarkdown"]
}
],
"data": null
}
Let’s fix those fields now by implementing the resolvers for two body fields.
Since we didn’t expose the body
field to the GraphQL schema it won’t be included in responses. On top of that we have
three fields that do not resolve within the ent
model for Note. Because of this, those fields have been changed to
resolver methods. Let’s update the markdown resolver to just return the body (since that’s how we’ll store the body):
// BodyMarkdown is the resolver for the bodyMarkdown field.
func (r *noteResolver) BodyMarkdown(ctx context.Context, obj *ent.Note) (string, error) {
return obj.Body, nil
}
Now for the HTML one, we want to have a Markdown parser (in this case Goldmark) convert the Markdown into HTML:
// BodyHTML is the resolver for the bodyHtml field.
func (r *noteResolver) BodyHTML(ctx context.Context, obj *ent.Note) (string, error) {
var buf bytes.Buffer
if err := goldmark.Convert([]byte(obj.Body), &buf); err != nil {
return "", err
}
return buf.String(), nil
}
Make sure you run go mod tidy
and/or go get github.com/yuin/goldmark
to ensure it’s downloaded and added to the
go.mod
and go.sum
.
Now restart your API and let’s see what we get back when we try to resolve those fields now:
query {
notes {
id
title
bodyHtml
bodyMarkdown
}
}
We should now get back the expected body data for our record this time:
{
"data": {
"notes": [
{
"id": 1,
"title": "Example",
"bodyHtml": "<p>Some <a href=\"https://google.com\">link</a>!</p>\n",
"bodyMarkdown": "Some [link](https://google.com)!"
}
]
}
}
If you stop and think about it, this is a feature of GraphQL (and gqlgen
) that is very powerful. We have this method
that will convert our Markdown into HTML only if we ask for the HTML from the server in our GraphQL request.
We have one more field for our GraphQL model that exists that doesn’t exist on the ent model (the nodeId
property).
This property is going to take a bit more effort to get implemented since this field is the ID
field for the Note.
Within GraphQL the ID
field should be globally unique. To get this done, we are going to add the NodeID resolver:
// NodeID is the resolver for the nodeId field.
func (r *noteResolver) NodeID(ctx context.Context, obj *ent.Note) (string, error) {
return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%d", note.Table, obj.ID))), nil
}
What we are doing is taking the table name (notes), joining it with a :
and the ID field of the Note then base64
encoding the value (so there aren’t encoding issues caused by the :
in the NodeID). For security reasons, you can use
something other than the table name. However, for this case we’ll just leave it as the table name. You will see when we
add the Noder method that you can set the first part to anything you want as long as you resolve it in the noder.
ent
that will automatically generate a NodeID property on the model that handles
this functionality for you. At some point, I plan to create this and make it available as an open source repo on GitHub.Now we can add a node(nodeId: ID!): Node
endpoint to our GraphQL API. This allows us to look up any entity of any type
given an ID
. In GraphQL the ID
field is supposed to be globally unique so we can find any resource using this
method. It may seem weird at first, but bear with us because this is a feature of GraphQL that is pretty cool.
Let’s start with adding the GraphQL query resolver for it in gql/schema/common.graphql
. When you’re done the common
schema should look like:
scalar Time
interface Node {
nodeId: ID!
}
type Query {
hello(name: String!): String!
node(nodeId: ID!): Node
}
Next, we need to add the model for Node to resolve to the ent
model instead of generating a new gql
model. Add
Node
to the models
section of gqlgen.yml
:
models:
# ...
Node:
model:
- github.com/nikkomiu/gentql/ent.Noder
We now need to tell Note
in GraphQL that it implements the Node
interface. Update the gql/schema/note.graphql
to
have the Note
type implement this:
type Note implements Node {
# ...
As always when we change the GraphQL schema files and/or the gqlgen.yml
, let’s regenerate:
go generate ./...
You should now see the new Node(context.Context) (ent.Noder, error)
method in the gql/common.go
. Let’s
implement this method:
// Node is the resolver for the node field.
func (r *queryResolver) Node(ctx context.Context, nodeID string) (ent.Noder, error) {
rawNodeID, err := base64.RawURLEncoding.DecodeString(nodeID)
if err != nil {
return nil, fmt.Errorf("failed to parse node id: base64 decode error")
}
splitNodeID := strings.Split(string(rawNodeID), ":")
if len(splitNodeID) != 2 {
return nil, fmt.Errorf("failed to parse node id: wrong number of parts")
}
switch splitNodeID[0] {
// add other cases here (custom table names, non-ent types, etc.)
case note.Table:
id, err := strconv.Atoi(splitNodeID[1])
if err != nil {
return nil, err
}
return r.ent.Noder(ctx, id, ent.WithFixedNodeType(splitNodeID[0]))
default:
return nil, fmt.Errorf("failed parse node id type")
}
}
Finally, let’s test out our new node
method on GraphQL (make sure to set your nodeId
to one that exists in your app):
query {
node(nodeId: "bm90ZXM6MQ") {
nodeId
... on Note {
title
}
}
}
We now have the ability to create a note, get a note by Node ID using the node()
resolver, and listing all Notes.
Let’s finish up by adding the update and delete methods to round out our CRUD operations. Open the
gql/note.go
and implement the UpdateNote(context.Context, int, model.NoteInput)
and
DeleteNote(context.Context, int)
methods like this:
// UpdateNote is the resolver for the updateNote field.
func (r *mutationResolver) UpdateNote(ctx context.Context, id int, input model.NoteInput) (*ent.Note, error) {
return r.ent.Note.UpdateOneID(id).
SetTitle(input.Title).
SetBody(input.Body).
Save(ctx)
}
// DeleteNote is the resolver for the deleteNote field.
func (r *mutationResolver) DeleteNote(ctx context.Context, id int) (bool, error) {
err := r.ent.Note.DeleteOneID(id).Exec(ctx)
if err != nil {
if ent.IsNotFound(err) {
return false, nil
}
return false, err
}
return true, nil
}
The UpdateNote(context.Context, int, model.NoteInput)
method is pretty straightforward and very similar to the
CreateNote(context.Context, model.NoteInput)
method except that we are updating instead of creating.
The DeleteNote(context.Context, int)
looks a bit more complex, but it is still pretty simple. We try to delete it and
if it doesn’t exist we return false with no error, if there’s an error we return false with the error, otherwise we
return true and no error. This way we can determine if the delete operation is ok, and it was actually performed.
We have covered a lot of ground in this section. Starting with adding the ent Client to our Resolver to adding Note models, resolvers, the node interface and resolver, and finishing off with the full CRUD operations for Notes. If you’ve made it this far, great job! This isn’t easy to do but with all of this set up hopefully you can see how this becomes much easier when we add additional models, resolvers, properties, etc.
Next up, we’re going to add sorting, filtering, and even the ability to search with clauses.