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
BaseSerializerif 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::SkipRequireAuthTokenfrom yourApi::Graphql::Indexaction.
- 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 MutationOutcometo 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!
 

 
    
Top comments (1)
This is really amazing I like it, is there any way you suggest we should configure graphql federation with Lucky?