DEV Community

Jeremy Woertink
Jeremy Woertink

Posted on

Using GraphQL with Lucky

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then require the GraphQL shard require to your ./src/shards.cr

# ...
require "avram"
require "lucky"
# ...
require "graphql"
Enter fullscreen mode Exit fullscreen mode

Lastly, before we go writing some code, let's generate our graph action.

lucky gen.action.api Api::Graphql::Index
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
 }"
}
Enter fullscreen mode Exit fullscreen mode

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 our current_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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/*"
# ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
})
Enter fullscreen mode Exit fullscreen mode

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:

  1. Remove the include Api::Auth::SkipRequireAuthToken from your Api::Graphql::Index action.
  2. Try to make a query call to me.
query Me {
  me {
    email
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice how you get an error telling you you're unauthorized.

  1. Update the MutationOutcome to include a token : String? property
  2. Set the token property to outcome.token = UserAuthToken.generate(authenticated_user).
  3. 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!

Discussion (1)

Collapse
scleexyz profile image
Sung-chul Lee

A good post! Nice job!