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.
- 922