Jeremy Woertink
Posted on April 7, 2021
This is how to get GraphQL running with Lucky Framework.
Preface
I have a total of just 1 app that uses GraphQL under my belt, so I'm by no means an expert. Chances are, this setup is "bad" in terms of using GraphQL; however, it's working... so with that said, here's how I got it running.
Setup
We need to get our Lucky app setup first. We can use a quick shortcut and skip the wizard 😬
lucky init.custom lucky_graph
cd lucky_graph
# Edit your config/database.cr if you need
Before we run the setup
script, we need to add our dependencies. We will add the GraphQL shard.
# shard.yml
dependencies:
graphql:
github: graphql-crystal/graphql
branch: master
Ok, now we can run our ./script/setup
to install our shards, setup the DB, and all that fun stuff. Do that now....
./script/setup
Then require the GraphQL shard require to your ./src/shards.cr
# ...
require "avram"
require "lucky"
# ...
require "graphql"
Lastly, before we go writing some code, let's generate our graph action.
lucky gen.action.api Api::Graphql::Index
This will generate a new action in your ./src/actions/api/graphql/index.cr
.
Graph Action
We generated an "index" file, but GraphQL does POST
requests... it's not quite "REST", but that's the whole point, right? 😅
Let's open up that new action file, and update to work our GraphQL.
# src/actions/api/graphql/index.cr
class Api::Graphql::Index < ApiAction
# NOTE: This is only for a test. I'll come back to it later
include Api::Auth::SkipRequireAuthToken
param query : String
post "/api/graphql" do
send_text_response(
schema.execute(query, variables, operation_name, Graph::Context.new(current_user?)),
"application/json",
200
)
end
private def schema
GraphQL::Schema.new(Graph::Queries.new, Graph::Mutations.new)
end
private def operation_name
params.from_json["operationName"].as_s
end
private def variables
params.from_json["variables"].as_h
end
end
There's a few things going on here, so I'll break them down.
send_text_response
It's true Lucky has a json()
response method, but that method takes an object and calls to_json
on it. In our case, the schema.execute()
will return a json string. So passing that in to json()
would result in a super escaped json object string "{\"key\":\"val\"}"
. We can use send_text_response
, and tell it to return a json content-type.
param query
When we make our GraphQL call from the front-end, our query will be the full formatted query (or mutation).
operation_name and variables
When you send the GraphQL POST from your client, it might look something like this:
{"operationName":"FeaturedPosts",
"variables":{"limit":20},
"query":"query FeaturedPosts($limit: Integer!) {
posts(featured: true, limit: $limit) {
title
slug
content
publishedAt
}
}"
}
We can pull out the operationName
, and the variables
allowing the GraphQL shard to do some magic behind the scenes.
A few extra classes
We have a few calls to some classes that don't exist, yet. We will need to add these next.
-
Graph::Context
- A class that will contain access to ourcurrent_user
-
Graph::Queries
- A class where we will define what our graphql queries will do -
Graph::Mutations
- A class where we will define what our graphql mutations will do
Graph objects
In GraphQL, you'll have all kinds of different objects to interact with. It's really its own mini little framework. You might have input objects, outcome objects, or possibly breaking your logic out in to mini bits. We can put all of this in to a new src/graph/
directory.
mkdir ./src/graph
Then make sure to require the new graph/
directory in ./src/app.cr
.
# ./src/app.cr
require "./shards"
# ...
require "./app_database"
require "./models/base_model"
# ...
require "./serializers/base_serializer"
require "./serializers/**"
# This should go here
# After your Models, Operations, Queries, Serializers
# but before Actions, Pages, Components, etc...
require "./graph/*"
# ...
require "./actions/**"
# ...
require "./app_server"
Next we will create all of the new Graph objects we will be using.
Graph::Context
Create a new file in ./src/graph/context.cr
# src/graph/context.cr
class Graph::Context < GraphQL::Context
property current_user : User?
def initialize(@current_user)
end
end
Graph::Queries
The Graph::Queries
object should contain methods that fetch data from the database. Generally these will use a Query object from your ./src/queries/
directory, or just piggy back off the current_user
object as needed.
Create a new file in ./src/graph/queries.cr
# src/graph/queries.cr
@[GraphQL::Object]
class Graph::Queries
include GraphQL::ObjectType
include GraphQL::QueryType
@[GraphQL::Field]
def me(context : Graph::Context) : UserSerializer?
if user = context.current_user
UserSerializer.new(user)
end
end
end
This query object starts with a single method me
which will return a serialized version of the current_user
if there is a current_user
. You'll notice all of the annotations. This GraphQL shard LOVES the annotations 😂
For our queries to return a Lucky::Serializer
object like UserSerializer
, we'll need to update it and tell it that it's a GraphQL object.
Open up ./src/serializers/user_serializer.cr
# src/serializers/user_serializer.cr
+ @[GraphQL::Object]
class UserSerializer < BaseSerializer
+ include GraphQL::ObjectType
def initialize(@user : User)
end
def render
- {email: @user.email}
end
+ @[GraphQL::Field]
+ def email : String
+ @user.email
+ end
end
That include could probably go in your
BaseSerializer
if you wanted.
Graph::Mutations
The Graph::Mutations
object should contain methods that mutate the data (i.e. create, update, destroy). Generally these will call to your Operation objects from your ./src/operations/
directory.
Create a new file in ./src/graph/mutations.cr
# src/graph/mutations.cr
@[GraphQL::Object]
class Graph::Mutations
include GraphQL::ObjectType
include GraphQL::MutationType
@[GraphQL::Field]
def login(email : String, password : String) : MutationOutcome
outcome = MutationOutcome.new(success: false)
SignInUser.run(
email: email,
password: password
) do |operation, authenticated_user|
if authenticated_user
outcome.success = true
else
outcome.errors = operation.errors.to_json
end
end
outcome
end
end
Notice the MutationOutcome
object here. We haven't created this yet, or mentioned it. The GraphQL shard requires that all of the methods have a return type signature, and that type has to be some supported object. This is just an example of what you could do, but really, the return object is up to you. You can have it return a UserSerializer?
as well if you wanted.
MutationOutcome
The idea here is that we have some sort of generic object. It has two properties success : Bool
and errors : String?
.
Create this file in ./src/graph/outcomes/mutation_outcome.cr
.
# src/graph/outcomes/mutation_outcome.cr
@[GraphQL::Object]
class MutationOutcome
include GraphQL::ObjectType
setter success : Bool = false
setter errors : String?
@[GraphQL::Field]
def success : Bool
@success
end
@[GraphQL::Field]
def errors : String?
@errors
end
end
By putting this in a nested outcomes directory, we can organize other potential outcomes we might want to add. We will need to require this directory right before the rest of the graph.
# update src/app.cr
require "./graph/outcomes/*"
require "./graph/*"
# ...
Checking the code
Before we continue on the client side, let's make sure our app boots and everything is good. We'll need some data in our database to test that our client code works.
Boot the app lucky dev
. There shouldn't be any compilation errors, but if there are, work through those, and I'll see you when you get back....
Back? Cool. Now that the app is booted, go to your /sign_up
page, and create an account. For this test, just use the email test@test.com
, and password password
. We will update this /me
page with some code to test that the graph works.
The Client
Now that the back-end is all setup, all we need to do is hook up the client side to actually make a call to the Graph.
For this code, I'm going to stick to very bare-bones. Everyone has their own preference as to how they want the client end configured, so I'll leave most of it up to you.
Add a button
Open up ./src/pages/me/show_page.cr
, and add a button
# src/pages/me/show_page.cr
class Me::ShowPage < MainLayout
def content
h1 "This is your profile"
h3 "Email: #{@current_user.email}"
# Add in this button
button "Send Test", id: "test-btn"
helpful_tips
end
# ...
end
Adding JS
We will add some setup code to ./src/js/app.js
to get the client configured.
// src/js/app.js
require("@rails/ujs").start();
require("turbolinks").start();
// ...
const sendGraphQLTest = ()=> {
fetch('/api/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
operationName: "Login",
variables: {email: "test@test.com", password: "password"},
query: `
mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) {
success
errors
}
}
`
})
})
.then(r => r.json())
.then(data => console.log('data returned:', data));
}
document.addEventListener("turbolinks:load", ()=> {
const btn = document.querySelector("#test-btn");
btn.addEventListener("click", sendGraphQLTest);
})
Save that, head over to your browser and click the button. In your JS console, you should see an output showing data.login.success
is true
!
Next Steps
Ok, we officially have a client side JS calling some GraphQL back in to Lucky. Obviously the client code isn't flexible, and chances are you're going to use something like Apollo anyway.
Before you go complicating the front-end, give this challenge a try:
- Remove the
include Api::Auth::SkipRequireAuthToken
from yourApi::Graphql::Index
action. - Try to make a query call to
me
.
query Me {
me {
email
}
}
Notice how you get an error telling you you're unauthorized.
- Update the
MutationOutcome
to include atoken : String?
property - Set the token property to
outcome.token = UserAuthToken.generate(authenticated_user)
. - Take the outcome token, and pass that back to make an authenticated call to the query Me.
Final thoughts
It's a ton of boilerplate, and setup... I get that, and I also think we can make it a lot better. If you have some ideas on making the Lucky / GraphQL connection better, or you see anything in this tutorial that doesn't quite follow a true graph flow, let me know! Come hop in to the Lucky Discord and we can chat more on how to take this to the next level.
UPDATE: It was brought up to me that the Serializer objects should probably move to Graph Type objects. With the serializers, the render
method is required to be defined, but if you don't have a separate API outside of GraphQL, then that render
method will never be called. You can remove the inheritence, and the render
method, and it should all still work!
Posted on April 7, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.