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!

Top comments (1)

Collapse
 
descholarceo profile image
descholar

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