GraphQL using Rails and React

@timrossinfo

The Problem

(Always start with a problem)

Search API

Slow to construct a response

High memory usage on server

Large response payload for mobile clients

Providing data for multiple contexts

GraphQL Spike

3x faster response time

Reduced memory usage on server

Initial response payload < 10kb

Simplified code on client and server

Problems with REST

Requires multiple requests, following links to other resources, or a deeply nested object graph

Returns additional data to handle different contexts, or use multiple contextual endpoints

API Discoverability

What is GraphQL?

GraphQL provides a way for developers to specify the precise data needed for a view and enables a client to fetch that data in a single network request.

Initially developed internally by Facebook in 2012 before being released publicly in 2015. Now hosted by the non-profit Linux Foundation.

{
  "data": {
    "listing": {
      "title": "Japanese Zen Retreat"
    }
  }
}
type Query {
  listing(id: ID!): Listing
}
query {
  listing(id: 674) {
    title
  }
}
type Listing {
  id: ID!
  title: String!
  description: String!
  photos: [Photo!]
}

type Photo {
  id: ID!
  image: PhotoImage!
  position: Int
}

type PhotoImage {
  hero: String!
  large: String!
  medium: String!
  original: String!
  thumb: String!
}

GraphiQL

An in-browser IDE for exploring GraphQL

GraphQL Ruby

https://graphql-ruby.org/

module Types
  class ListingType < Types::BaseObject
    field :id, ID, null: false
    field :title, String, null: false
    field :description, String, null: false
    field :photos, [PhotoType], null: true

    def photos
      AssociationLoader.for(Listing, :photos).load(object)
    end
  end
end

Object Types

module Types
  class ListingStatesType < BaseEnum
    value 'NSW', 'New South Wales'
    value 'VIC', 'Victoria'
  end
end

Enumeration Types

module Types
  module LookupType
    include Types::BaseInterface

    field :name, String, null: false
    field :slug, String, null: false
  end
end

Interface Types

module Types
  class AmenityType < Types::BaseObject
    implements Types::LookupType
  end
end
module Types
  class SearchResultType < Types::BaseUnion
    possible_types Types::ListingType, Types::StoryType

    def self.resolve_type(object, _context)
      if object.is_a?(Story)
        Types::StoryType
      else
        Types::ListingType
      end
    end
  end
end

Union Types

{
  search(params: {
    type: accommodation,
    state: VIC
  }) {
    results {
      __typename
      ... on Story {
        featuredListing {
          title
        }
      }
      ... on Listing {
        subcategory {
          name
        }
      }
    }
  }
}

Query using a Union

Authorization

Visibility

Hide parts of the schema from some users

Accessibility

Reject certain queries from unauthorized users

Authorization

Check if current user has permission to access object

Scoping

Filter a list of objects appropriate for the current user or context

module Types
  class AdminFeature < Types::BaseObject
    def self.visible?(context)
      # only show fields with this type to admin users
      super && context[:current_user].admin?
    end
  end
end

Visibility

field :abn_lookup, ABNLookupType, null: true do
  argument :abn, String, required: true

  def accessible?(context)
    super && context[:current_user]
  end
end

Accessibility

This is different from visibility, where unauthorized parts of the schema are treated as non-existent. It’s also different from authorization, which makes checks while running, instead of before running.

DEPRECATED
module Types
  class Conversation < Types::BaseObject
    # You can only see the details of a `Conversation`
    # if you're one of the people involved in it.
    def self.authorized?(object, context)
      super && object.participants.include?(
      	context[:current_user]
      )
    end
  end
end

Authorization

While a query is running, you can check each object to see whether the current user is authorized to interact with that object.

module Types
  class Conversation < Types::BaseObject
    def self.scope_items(items, context)
      items.where(participant: context[:current_user])
    end
  end
end

module Types
  class User < Types::BaseObject
    field :conversations, [Conversation], scope: true
  end
end

Scoping

Rather than checking “can this user see this thing?”, scoping filters items appropriate for the current user and context

module Types
  class QueryType < Types::BaseObject
    field :conversation, Conversation, null: false do
      argument :id, ID, required: true
    end

    def conversation(:id)
      # Find conversation through current user
      context[:current_user].conversations.find(id)
    end
  end
end

Scoping with Active Record

My preference is to always access records through the scope of the current user

Query Batching

https://github.com/Shopify/graphql-batch

module Types
  class StoryType < Types::BaseObject
    field :featured_listing, ListingType, null: false
    
    def featured_listing
      RecordLoader.for(Listing).load(object.listing_id)
    end
  end
end
query {
  stories {
    id
    featured_listing {
      title
    }
  }
}

No N+1

Mutations

module Types
  class MutationType < Types::BaseObject
    field :create_booking, mutation: Mutations::CreateBooking
  end
end
module Types
  class BookingInputType < Types::BaseInputObject
    argument :start_date, Types::DateType, required: true
    argument :end_date, Types::DateType, required: true
    argument :adults, Int, required: true
    argument :children, Int, required: true
  end
end
module Mutations
  class CreateBooking < BaseMutation
    argument :listing_id, ID, required: true
    argument :booking_input, Types::BookingInputType, required: true

    field :booking, Types::BookingType, null: true

    def resolve(listing_id:, booking_input:)
      listing = Listing.active.find(listing_id)
      booking = ::Listings::CreateBooking.call(
        listing, booking_input.to_h, context[:current_user]
      )
      {
        booking: booking
      }
    end

    def ready?(**_args)
      return true if context[:current_user].present?

      raise GraphQL::ExecutionError, 'Login required'
    end
  end
end
mutation {
  createBooking(
    listingId: 490,
    bookingInput: {
    	startDate: "2019-10-01",
    	endDate: "2019-10-03",
    	adults: 2,
    	children:0
    },
  ) {
    booking {
      id
      startDate
      endDate
    }
  }
}
{
  "data": {
    "createBooking": {
      "booking": {
        "id": "18533",
        "startDate": "2019-10-01",
        "endDate": "2019-10-03"
      }
    }
  }
}

Error Handling

https://github.com/exAspArk/graphql-errors

GraphQL::Errors.configure(RiparideSchema) do
  rescue_from ActiveRecord::RecordNotFound do
    nil
  end

  rescue_from ActiveRecord::RecordInvalid do |exception|
    GraphQL::ExecutionError.new(
      exception.record.errors.full_messages.join("\n")
    )
  end

  rescue_from UnprocessableEntity do |exception|
    GraphQL::ExecutionError.new(exception.message)
  end
end

Apollo Client

https://www.apollographql.com/docs/react/

import gql from 'graphql-tag'
import { useQuery } from '@apollo/react-hooks'

const GET_LISTINGS = gql`
  {
    listings {
      id
      title
    }
  }
`

Define Query

const Listings = () => {
  const { loading, error, data } = useQuery(GET_LISTINGS)

  if (loading) return 'Loading...'

  return (
    <select name="listing">
      {data.listings.map(listing => (
        <option key={listing.id} value={listing.id}>
          {listing.title}
        </option>
      ))}
    </select>
  )
}

Use Query in Component

React Hook

import React, { useState } from 'react'

const Example = () => {
  // Declare a new state variable, which we'll call "count"
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  )
}

React Hooks

Hooks let you use state and other React features without writing a class

import React, { useState, useEffect } from 'react'

const Example = () => {
  // Declare a new state variable, which we'll call "count"
  const [count, setCount] = useState(0)

  // Similar to componentDidMount and componentDidUpdate:
  useEffect(() => {
    // Update the document title using the browser API
    document.title = `You clicked ${count} times`
  })

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  )
}

Mutations

import React, { useMutation } from 'react'

const Example = ({ listingId, startDate, endDate, adults, children }) => {
  const [createBooking, { loading, data }] = useMutation(CREATE_BOOKING, 
    variables: {
      listingId,
      bookingInput: {
        startDate,
        endDate,
        adults,
        children
      }
    }
  )
  
  return (
    <div>
      <button onClick={createBooking} disabled={loading}>
        Create Booking
      </button
    </div>
  )
}

useMutation hook

fragment NameParts on User {
  firstName
  lastName
}

query GetUser {
  user(id: "7") {
    ...NameParts
    avatar(size: LARGE)
  }
}

Fragments

A GraphQL fragment is a shared piece of query logic.

  • Share fields between multiple queries.
  • Break up queries to co-locate fields in places where they are used.

Caching Data

Query Batching

Final thoughts

Gradually migrate an existing API

Continue to use REST for simple APIs

Carefully consider authorization

Resources

GraphQL Official Site

https://graphql.org/

GraphQL Ruby

https://graphql-ruby.org/

Apollo Client

https://www.apollographql.com/docs/react/

GraphQL using Rails and React

By timrossinfo

GraphQL using Rails and React

In this talk, I will discuss a recent transition from a REST API to GraphQL, the challenges we faced and the problems we solved. I will also demonstrate how we are using GraphQL with a Rails backend and React client.

  • 942