GraphQL on Rails: On the way to perfection

Cover for GraphQL on Rails: On the way to perfection

Topics


Translations

A hitchhiker’s guide to developing GraphQL applications with Rails on the backend and React/Apollo on the frontend. The third and final part of this tutorial is all about real-time updates, as well as about DRY-ing up our code and implementing better error handling.

In the previous parts of this tutorial we have built the prototype of a Martian Library application: a user can dynamically manage a list of artifacts related to the Red Planet in a modern SPA-fashion. It’s not quite the time to sit back and relax though, because we still have some refactoring to do.

If you have been coding along for the past two parts—feel free to keep using your code, if not—pull it from this repo.

All you need is DRY

Let’s start with the backend and DRY-up our items’ mutations (AddItemMutation and UpdateItemMutation) a bit. We have some duplicated code that verifies whether a user is logged in:

# app/graphql/mutations/add_item_mutation.rb

module Mutations
  class AddItemMutation < Mutations::BaseMutation
    # ...

    def resolve
      if context[:current_user].nil?
        raise GraphQL::ExecutionError,
              "You need to authenticate to perform this action"
      end

      save_item
    end
  end
end

Let’s move it into the BaseMutation class:

# app/graphql/mutations/base_mutation.rb

module Mutations
  class BaseMutation < GraphQL::Schema::Mutation
    def check_authentication!
      return if context[:current_user]

      raise GraphQL::ExecutionError,
            "You need to authenticate to perform this action"
    end
  end
end

After this change, you can replace the above snippet in the AddItemMutation and UpdateItemMutation with the check_authentication! call. That is just one example of how we can use the BaseMutation. In a real-world application it can contain many useful helpers for repetitive tasks.

Now, let’s take a look at our frontend code. What kind of duplication do we have here?

Duplication

Frontend duplication

These queries look very similar: the fields we select in Item queries are almost the same. How can we avoid repetition?

Luckily, GraphQL has its own “variables” called fragments. A fragment is a named set of fields on a specific type.

Time to create our first fragment:

$ mkdir -p app/javascript/fragments && touch app/javascript/fragments/Item.graphql

Put all the repeating fields into it:

fragment ItemFragment on Item {
  id
  title
  imageUrl
  description
}

Now we need to add fragments to all operations in AddItemForm, UpdateItemForm and Library. For instance, this is how the query should look in the Library component:

#app/javascript/components/Library/operations.graphql
#import '../../fragments/Item.graphql'

query LibraryQuery {
  items {
    ...ItemFragment
    user {
      id
      email
    }
  }
}

Dealing with errors

As we know, GraphQL always responds with 200 OK, if the action has not caused the server-side error. Two types of errors usually happen: user input-related errors (validations) and exceptions.

  • Validation errors can appear only in mutations and they are included in the data that is sent back. They are meant to provide useful feedback to the user and could be displayed in the UI.

  • Exceptions could happen in any query and signal that something went wrong with the query: for example, authentication/authorization issues, unprocessable input data, etc. (see below). The client must “fail hard” if a response contains an exception (e.g., show an error screen).

What can we do with errors from the frontend perspective?

First, we can set up an error logger to quickly detect and fix errors (we already configured it in the first part of this guide).

Second, it would be a good idea to wrap components in error boundaries and show error screens with sad developer faces when something goes wrong.

Third, we should try to avoid common mistakes by looking at the documentation. Beware of dots and handle nullable fields properly! Look up the me query in your GraphiQL docs:

Component structure

GraphiQL auto-generated documentation

According to the documentation, me is a nullable field. We cannot use an expression like me.email, for example. We need to make sure that the user exists.

Finally, we should process GraphQL errors inside the render prop function. We will show you how to do this soon.

When the user submits the invalid data, our backend returns a list of error messages as strings. Let’s change the way we resolve errors: we will return an object, containing the same list of error messages, but also some JSON-encoded details. Details could be used to generate messages client-side or provide additional feedback to users (e.g., highlight the invalid form field).

First of all, let’s define a new ValidationErrorsType:

# app/graphql/types/validation_errors_type.rb

module Types
  class ValidationErrorsType < Types::BaseObject
    field :details, String, null: false
    field :full_messages, [String], null: false

    def details
      object.details.to_json
    end
  end
end

Now, we need to change our AddItemMutation to use the new type we defined (please do the same thing for the UpdateItemMutation):

# app/graphql/mutations/add_item_mutation.rb

module Mutations
  class AddItemMutation < Mutations::BaseMutation
    argument :title, String, required: true
    argument :description, String, required: false
    argument :image_url, String, required: false

    field :item, Types::ItemType, null: true
    field :errors, Types::ValidationErrorsType, null: true # this line has changed

    def resolve(title:, description: nil, image_url: nil)
      check_authentication!

      item = Item.new(
        title: title,
        description: description,
        image_url: image_url,
        user: context[:current_user]
      )

      if item.save
        { item: item }
      else
        { errors: item.errors } # change here
      end
    end
  end
end

Finally, let’s add a couple of validations to the Item model:

# app/models/item.rb

class Item < ApplicationRecord
  belongs_to :user

  validates :title, presence: true
  validates :description, length: { minimum: 10 }, allow_blank: true
end

Now we need to use these validations in our interface. We should update logic for AddItemForm and UpdateItemForm. We will show you how to do it for AddItemForm. The code for UpdateItemForm we will leave as an exercise for the reader (you can check the solution here).

Let’s add an errors field to operations.graphql first:

#/app/javascript/components/AddItemForm/operations.graphql
#import '../../fragments/Item.graphql'

mutation AddItemMutation(
  $title: String!
  $description: String
  $imageUrl: String
) {
  addItem(title: $title, description: $description, imageUrl: $imageUrl) {
    item {
      ...ItemFragment
      user {
        id
        email
      }
    }
    errors { # new field
      fullMessages
    }
  }
}

Now we need to make a minor change in AddItemForm and its parent ProcessItemForm to add a new element for errors:

We are adding a new errors property to ProcessItemForm and a new element to show errors.

// app/javascript/components/ProcessItemForm/index.js
const ProcessItemForm = ({
  // ...
  errors,
}) => {
  // ...
  return (
    <div className={cs.form}>
      {errors && (
        <div className={cs.errors}>
          <div className="error">{errors.fullMessages.join('; ')}</div>
        </div>
      )}
      {/* ... */}
    </div>
  );
};

export default ProcessItemForm;

When working with the Mutation component, we are grabbing errors from the data property:

// app/javascript/components/AddItemForm/index.js
// ...
<Mutation mutation={AddItemMutation}>
  {(addItem, { loading, data }) => ( // getting data from response
    <ProcessItemForm
      buttonText="Add Item"
      loading={loading}
      errors={data && data.addItem.errors} />
      // ...
    )
  }
</Mutation>

If you want to make your errors appear a little bit nicer, add the following styles to
/app/javascript/components/ProcessItemForm/styles.module.css:

.form {
  position: relative;
}

.errors {
  position: absolute;
  top: -20px;
  color: #ff5845;
}

Now, let’s talk about the second type of GraphQL errors: exceptions. In the previous part, we have implemented the authentication, but we did not implement a way to handle a user with a non-existent email. It is not the expected behavior, so let’s make sure to raise an exception:

# app/graphql/mutations/sign_in_mutation.rb

module Mutations
  class SignInMutation < Mutations::BaseMutation
    argument :email, String, required: true

    field :token, String, null: true
    field :user, Types::UserType, null: true

    def resolve(email:)
      user = User.find_by!(email: email)

      token = Base64.encode64(user.email)

      {
        token: token,
        user: user
      }
    rescue ActiveRecord::RecordNotFound
      raise GraphQL::ExecutionError, "user not found"
    end
  end
end

We need to change our frontend code to handle this situation gracefully. Let’s do it for the UserInfo component. Grab the error parameter from the object provided by render prop function for the Mutation component:

// app/javascript/components/UserInfo/index.js

const UserInfo = () => {
  // ...
  {(signIn, { loading: authenticating, error /* new key */ }) => {
  }}
  // ...
}

And add an element that displays an error just before the closing </form> tag:

// app/javascript/components/UserInfo/index.js

const UserInfo = () => {
  <form>
    // ...
    {error && <span>{error.message}</span>}
  </form>
  // ...
}

Handling input data

Let’s come back to AddItemMutation and UpdateItemMutation mutations again. Take a look at the argument list and ask yourself, why do I have two almost identical lists? Every time we add a new field to the Item model we would need to add a new argument twice, and that is not good.

The solution is fairly simple: let’s use a single argument containing all the fields we need. graphql-ruby comes with a special primitive called BaseInputObject, which is designed to define a type for arguments like this. Let’s create a file named item_attributes.rb:

# app/graphql/types/item_attributes.rb

module Types
  class ItemAttributes < Types::BaseInputObject
    description "Attributes for creating or updating an item"

    argument :title, String, required: true
    argument :description, String, required: false
    argument :image_url, String, required: false
  end
end

This looks a lot like the types we have created before, but with a different base class and arguments instead of fields. Why is that? GraphQL follows CQRS principle and comes up with two different models for working with data: read model (type) and write model (input).

Heads up: you cannot use complex types as the argument type—it can only be a scalar type or another input type!

Now we can change our mutations to use our handy argument. Let’s start with AddItemMutation:

# app/graphql/mutations/add_item_mutation.rb

module Mutations
  class AddItemMutation < Mutations::BaseMutation
    argument :attributes, Types::ItemAttributes, required: true # new argument

    field :item, Types::ItemType, null: true
    field :errors, Types::ValidationErrorsType, null: true # <= change here

    # signature change
    def resolve(attributes:)
      check_authentication!

      item = Item.new(attributes.to_h.merge(user: context[:current_user])) # change here

      if item.save
        { item: item }
      else
        { errors: item.errors }
      end
    end
  end
end

As you can see, we have replaced a list of arguments with a single argument named attributes, changed #resolve signature to accept it, and slightly changed the way we create the item. Please make the same changes in UpdateItemMutation. Now we need to change our frontend code to work with these changes.

The only thing we need to do is to add one word and two brackets to our mutation (the same change should be done for UpdateItem):

#/app/javascript/components/AddItemForm/operations.graphql
#import '../../fragments/Item.graphql'

mutation AddItemMutation(
  $title: String!
  $description: String
  $imageUrl: String
) {
  addItem(
    attributes: { # just changing the shape
      title: $title
      description: $description
      imageUrl: $imageUrl
    }
  ) {
    item {
      ...ItemFragment
      user {
        id
        email
      }
    }
    errors {
      fullMessages
    }
  }
}

Implementing real-time updates

Server-initiated updates are common in modern applications: in our case, it might be helpful for our user to have the list updated when someone adds a new or changes an existing item. This is exactly what GraphQL subscriptions are for!

Subscription is a mechanism for delivering server-initiated updates to the client. Each update returns the data of a specific type: for instance, we could add a subscription to notify the client when a new item is added. When we send Subscription operation to the server, it gives us an Event Stream back. You can use anything, including post pigeons, to transport events, but Websockets are especially suitable for that. For our Rails application, it means we can use ActionCable for transport. Here is what a typical GraphQL subscription looks like:

Subscriptions example

Laying the cable

First, we should create app/graphql/types/subscription_type.rb and register the subscription, which is going to be triggered when the new item is added.

# app/graphql/types/subscription_type.rb

module Types
  class SubscriptionType < GraphQL::Schema::Object
    field :item_added, Types::ItemType, null: false, description: "An item was added"

    def item_added; end
  end
end

Second, we should configure our schema to use ActionCableSubscriptions and look for the available subscriptions in the SubscriptionType:

# app/graphql/martian_library_schema.rb

class MartianLibrarySchema < GraphQL::Schema
  use GraphQL::Subscriptions::ActionCableSubscriptions

  mutation(Types::MutationType)
  query(Types::QueryType)
  subscription(Types::SubscriptionType)
end

Third, we should generate an ActionCable channel for handling subscribed clients:

$ rails generate channel GraphqlChannel

Let’s borrow the implementation of the channel from the docs:

# app/channels/graphql_channel.rb

class GraphqlChannel < ApplicationCable::Channel
  def subscribed
    @subscription_ids = []
  end

  def execute(data)
    result = execute_query(data)

    payload = {
      result: result.subscription? ? { data: nil } : result.to_h,
      more: result.subscription?
    }

    @subscription_ids << context[:subscription_id] if result.context[:subscription_id]

    transmit(payload)
  end

  def unsubscribed
    @subscription_ids.each do |sid|
      MartianLibrarySchema.subscriptions.delete_subscription(sid)
    end
  end

  private

  def execute_query(data)
    MartianLibrarySchema.execute(
      query: data["query"],
      context: context,
      variables: data["variables"],
      operation_name: data["operationName"]
    )
  end

  def context
    {
      current_user_id: current_user&.id,
      current_user: current_user,
      channel: self
    }
  end
end

Make sure to pass :channel to the context. Also, we pass current_user to make it available inside our resolvers, as well as :current_user_id, which can be used for passing scoped subscriptions.

Now we need to add a way to fetch current user in our channel. Change ApplicationCable::Connection in the following way:

# app/channels/application_cable/connection.rb

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = current_user
    end

    private

    def current_user
      token = request.params[:token].to_s
      email = Base64.decode64(token)
      User.find_by(email: email)
    end
  end
end

Triggering the event is fairly simple: we should pass the camel-cased field name as the first argument, options as the second argument, and the root object of the subscription update as the third argument. Add it to the AddItemMutation:

# app/graphql/mutations/add_item_mutation.rb

module Mutations
  class AddItemMutation < Mutations::BaseMutation
    argument :attributes, Types::ItemAttributes, required: true

    field :item, Types::ItemType, null: true
    field :errors, [String], null: false

    def resolve(attributes:)
      check_authentication!

      item = Item.new(attributes.merge(user: context[:current_user]))

      if item.save
        MartianLibrarySchema.subscriptions.trigger("itemAdded", {}, item)
        { item: item }
      else
        { errors: item.errors.full_messages }
      end
    end
  end
end

Argument hash can contain the arguments, which are defined in the subscription (which will be passed as resolver arguments). There is an optional fourth argument called :scope which allows you to limit the scope of users who will receive the update.

Let’s another subscription, this time for updating our items:

# app/graphql/types/subscription_type.rb

module Types
  class SubscriptionType < GraphQL::Schema::Object
    field :item_added, Types::ItemType, null: false, description: "An item was added"
    field :item_updated, Types::ItemType, null: false, description: "Existing item was updated"

    def item_added; end
    def item_updated; end
  end
end

This is how we should trigger this kind of update in the UpdateItemMutation:

# app/graphql/mutations/update_item_mutation.rb

module Mutations
  class UpdateItemMutation < Mutations::BaseMutation
    argument :id, ID, required: true
    argument :attributes, Types::ItemAttributes, required: true

    field :item, Types::ItemType, null: true
    field :errors, [String], null: false

    def resolve(id:, attributes:)
      check_authentication!

      item = Item.find(id)

      if item.update(attributes.to_h)
        MartianLibrarySchema.subscriptions.trigger("itemUpdated", {}, item)
        { item: item }
      else
        { errors: item.errors.full_messages }
      end
    end
  end
end

We should mention that the way subscriptions are implemented in graphql-ruby for ActionCable can be a performance bottleneck: a lot of Redis round-trips, and query re-evaluation for every connected client (see more in this in-depth explanation here).

Plugging in

To teach our application to send data to ActionCable, we need some configuration. First, we need to install some new modules to deal with Subscriptions via ActionCable:

$ yarn add actioncable graphql-ruby-client

Then, we need to add some new magic to /app/javascript/utils/apollo.js

// /app/javascript/utils/apollo.js
...
import ActionCable from 'actioncable';
import ActionCableLink from 'graphql-ruby-client/subscriptions/ActionCableLink';
...
const getCableUrl = () => {
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  const host = window.location.hostname;
  const port = process.env.CABLE_PORT || '3000';
  const authToken = localStorage.getItem('mlToken');
  return `${protocol}//${host}:${port}/cable?token=${authToken}`;
};

const createActionCableLink = () => {
  const cable = ActionCable.createConsumer(getCableUrl());
  return new ActionCableLink({ cable });
};

const hasSubscriptionOperation = ({ query: { definitions } }) =>
  definitions.some(
    ({ kind, operation }) =>
      kind === 'OperationDefinition' && operation === 'subscription'
  );


//..
// we need to update our link
  link: ApolloLink.from([
    createErrorLink(),
    createLinkWithToken(),
    ApolloLink.split(
      hasSubscriptionOperation,
      createActionCableLink(),
      createHttpLink(),
    ),
  ]),

//..

Despite the fact that the code looks a bit scary, the idea is simple:

  • we create a new Apollo link for subscriptions inside createActionCableLink;
  • we decide where to send our data inside ApolloLink.split;
  • if hasSubscriptionOperation returns true, the operation will be sent to actionCableLink.

Now we need to create a new component by using our generator:

$ npx @hellsquirrel/create-gql-component create /app/javascript/components/Subscription

Let’s add our subscription to operations.graphql:

#/app/javascript/components/Subscription/operations.graphql
#import '../../fragments/Item.graphql'

subscription ItemSubscription {
  itemAdded {
    ...ItemFragment
    user {
      id
      email
    }
  }

  itemUpdated {
    ...ItemFragment
    user {
      id
      email
    }
  }
}

Nothing new, right? Let’s create the Subscription component:


// /app/javascript/components/Subscription/index.js
import React, { useEffect } from 'react';
import { ItemSubscription } from './operations.graphql';

const Subscription = ({ subscribeToMore }) => {
  useEffect(() => {
    return subscribeToMore({
      document: ItemSubscription,
      updateQuery: (prev, { subscriptionData }) => {
        if (!subscriptionData.data) return prev;
        const { itemAdded, itemUpdated } = subscriptionData.data;

        if (itemAdded) {
          const alreadyInList = prev.items.find(e => e.id === itemAdded.id);
          if (alreadyInList) {
            return prev;
          }

          return { ...prev, items: prev.items.concat([itemAdded]) };
        }

        if (itemUpdated) {
          return {
            ...prev,
            items: prev.items.map(el =>
              el.id === itemUpdated.id ? { ...el, ...itemUpdated } : el
            ),
          };
        }

        return prev;
      },
    });
  }, []);
  return null;
};

export default Subscription;

One more hook! Now it’s useEffect. It’s called on initial render and reruns whenever the user changes.

We are asking our hook to subscribe to add and update event streams. We are adding or updating items when the corresponding event is fired.

The last step is to add Subscription component to Library at the end of the last div inside Query component:

import Subscription from '../Subscription';
//...
const Library = () => {
  const [item, setItem] = useState(null);
  return (
    <Query query={LibraryQuery}>
      {({ data, loading, subscribeToMore /* we need subscribe to more arg */}) => (
        <div>
          // ...
          <Subscription subscribeToMore={subscribeToMore} />
        </div>
      )}
    </Query>
  );
};
//...

The Query component from the react-apollo library provides the special function subscribeToMore which is used by the Subscription component. We are passing this function to our Subscription component.

Now we are ready to test our subscriptions! Try to add a new item or change the existing one in a different browser tab—you should see the changes appearing in all the tabs you have open.

Congratulations! 🥳

It is the end of our exciting and adventurous journey through Ruby-GraphQL-Apollo world. Using our small application as an example, we practiced all the basic techniques, highlight common problems, and introduce some advanced topics.

This might have been a challenging exercise, but we are certain you will benefit from it in the future. In any case, you now have enough theory and practice to create your own Rails applications that leverage the power of GraphQL!

Join our email newsletter

Get all the new posts delivered directly to your inbox. Unsubscribe anytime.