Shopify has some great tutorials on how to build apps using Node.js + React and Ruby and Sinatra - but the tutorials they have with Rails doesn't explain how to integrate it with React or GraphQL. And as Shopify is investing a lot in them, I decided to write this blog to help future developers who are looking to build an app using Rails, React and GraphQL.
I am going to walk you through my workflow on building a Shopify app with Rails and React, and using GraphQL to communicate between the two. We'll also use GraphQL to talk to the Shopify APIs. This post assumes that you already have setup Rails and React with Webpacker, and that you are familiar with GraphQL. If you are just starting out and need help setting up Rails, React or GraphQL, here are a few good resources.
High-level requirements
By the end of this tutorial, we are going to successfully import products from the Shopify Admin API and display it on our app. This list is a high-level breakdown of how we are going to approach this:
- Connecting to Shopify
- Retrieving product information from Shopify
- Storing the products in your database
- Displaying the products
-
Connecting to Shopify
I use two gems (both created by Shopify) to access the admin section programmatically. They provide the necessary controllers and all the required code for authenticating via OAuth. Do follow the steps mentioned in these gems to create an app, request access, and to get an access token.
You should also create the necessary models - Shop
, Product
and Image
- to your app.
class Shop < ApplicationRecord
include ShopifyApp::SessionStorage
has_many :products, dependent: :destroy
def api_version
ShopifyApp.configuration.api_version
end
end
class Product < ApplicationRecord
belongs_to :shop
has_many :images, dependent: :destroy
end
class Image < ApplicationRecord
belongs_to :product
end
-
Retrieving product information from Shopify
The first thing to do when a new customer downloads the app is to retrieve all their products from the store. For this, we can use an after_create
Active Record callback to automatically start the download.
class Shop < ApplicationRecord
...
after_create :download_products
def download_products
Shopify::DownloadProductsWorker.perform_async(id)
end
...
end
I do this via a background worker via Sidekiq. Most of the stores will have 100 - 1000s of products and you don't want to keep the user waiting while your app is downloading the products.
module Shopify
class DownloadProductsWorker
include Sidekiq::Worker
def perform(shop_id)
DownloadProductsFromShopify.call!(shop_id: shop_id)
end
end
end
The above worker delegates this process to an interactor. Interactors serve as a one-stop place to store all the business logic for the app. Another bonus is that it handles background failures and retries the worker easily. By default, Sidekiq only retries for StandardErrors. By moving all the logic to an interactor, and using .call!
it throws an exception of type Interactor::Failure
, which in-turn makes the Sidekiq worker to also fail, and re-try the job again for any error.
class DownloadProductsFromShopify
include Interactor::Organizer
organize ActivateShopifySession, DownloadProducts, DeactivateShopifySession
end
While downloading the products from Shopify, we have to first activate the session, download the products and then deactivate the Shopify session.
I've put this into an organiser which does these three steps one after the other. By separating these three requirements into their own classes, we can re-use them in other places.
Below are the two interactors for activating and deactivating the Shopify session.
class ActivateShopifySession
include Interactor
def call
ActiveRecord::Base.transaction do
find_shop
create_session_object
activate_session
end
end
private
def find_shop
context.shop = Shop.find(context.shop_id)
end
def create_session_object
shop = context.shop
domain = shop.shopify_domain
token = shop.shopify_token
api_version = Rails.application.credentials.api_version
context.shopify_session = ShopifyAPI::Session.new(domain: domain, token: token, api_version: api_version)
end
def activate_session
ShopifyAPI::Base.activate_session(context.shopify_session)
end
end
class DeactivateShopifySession
include Interactor
def call
ShopifyAPI::Base.clear_session
end
end
-
Downloading products from Shopify
The DownloadProducts
interactor is responsible for downloading all the products from the Shopify store.
class DownloadProducts
include Interactor
def call
ActiveRecord::Base.transaction do
activate_graphql_client
structure_the_query
make_the_query
poll_status_of_bulk_query
retrieve_products
end
end
end
It connects to Shopify's GraphQL client, structures the query and gets the results from Shopify. With Shopify's GraphQL Admin API, we can use bulk operations to asynchronously fetch data in bulk.
class DownloadProducts
...
private
def activate_graphql_client
context.client = ShopifyAPI::GraphQL.client
end
def structure_the_query
context.download_products_query = context.client.parse <<-'GRAPHQL'
mutation {
bulkOperationRunQuery(
query: """
{
products {
edges {
node {
id
title
images {
edges {
node {
id
originalSrc
}
}
}
}
}
}
}
"""
) {
bulkOperation {
id
status
}
userErrors {
field
message
}
}
}
GRAPHQL
end
def make_the_query
context.result = context.client.query(context.download_products_query)
end
def poll_status_of_bulk_query
context.poll_status_query = context.client.parse <<-'GRAPHQL'
query {
currentBulkOperation {
id
status
errorCode
createdAt
completedAt
objectCount
fileSize
url
partialDataUrl
}
}
GRAPHQL
context.result_poll_status = context.client.query(context.poll_status_query)
end
...
end
When the operation is complete, the results are delivered in the form of a JSONL file that Shopify makes available at a URL. We can use this URL to download all the products and images, and store them in our database.
require 'open-uri'
class DownloadProducts
...
def download_products
images = []
products = []
URI.open(context.url) do |f|
f.each do |line|
json = JSON.parse(line)
if json.key?('originalSrc')
image_id = json['id'].delete('^0-9')
image_product_id = json['__parentId'].delete('^0-9')
image_url = json['originalSrc']
image = Image.new(shopify_image_id: image_id,
shopify_image_product_id: image_product_id,
url: image_url,
shop_id: context.shop.id)
images << image
else
prodcut_id = json['id'].delete('^0-9')
prodcut_title = json['title']
product = Product.new(title: prodcut_title,
shopify_product_id: prodcut_id,
shop_id: context.shop.id)
products << product
end
end
end
Image.import images, recursive: true, on_duplicate_key_ignore: true
Product.import products, recursive: true, on_duplicate_key_ignore: true
end
end
Using GraphQl with the activerecord-import
gem, improves the performance of the app. We can download 1000s of products and store them in the database, with just 2 SQL calls - one for bulk storing all the products, and one for storing the images.
GraphQL
Before we discuss the logic for downloading all the products, we need to talk about GraphQL. GraphQL is a query language for interacting with an API. Few advantage of GraphQL over REST APIs are
- GraphQL only provides the data you ask for, reducing bandwidth and overhead, and usually improves the speed of your app.
- Unlike REST APIs, which uses multiple endpoints to return large sets of data, GraphQL uses a single endpoint.
- When downloading 1000s of products it is faster to download them via GraphQL's bulk queries.
-
Setting up GraphQL types and queries
I've used the following gems for working with GraphQL.
# GraphQL
gem 'graphql'
gem 'graphql-batch'
gem 'graphql-client'
gem 'graphql-guard'
gem 'apollo_upload_server', '2.0.1'
As we want to download products and images from a shop, we need to define GraphQL types for all them individually.
module Types
class ShopType < Types::BaseObject
field :id, ID, null: false
field :shopify_domain, String, null: true
field :shopify_token, String, null: true
field :products, [Types::ProductType], null: true
def products
AssociationLoader.for(Shop, :products).load(object)
end
end
end
The AssociationLoader
comes from graphql-batch, another gem built by Shopify, which is useful for handling N+1 errors on GraphQL.
Similarly, we also need to define the Product and Image Graphql Types.
module Types
class ProductType < Types::BaseObject
field :id, ID, null: true
field :title, String, null: true
field :shop, Types::ShopType, null: true
...
field :images, [Types::ImageType], null: true
end
end
module Types
class ImageType < Types::BaseObject
field :id, ID, null: true
field :url, String, null: true
...
field :product, Types::ProductType, null: true
end
end
This allows us to create a ProductsResolver
which can be used to query all the products from a shop.
module Resolvers
class ProductsResolver < Resolvers::BaseResolver
type [Types::ProductType], null: false
def resolve
context[:current_shop].products.includes(:images)
end
end
end
context[:current_shop]
is being set in the GraphqlController.
class GraphqlController < AuthenticatedController
before_action :set_current_shop
before_action :set_context
before_action :set_operations
def execute
if @operations.is_a? Array
queries = @operations.map(&method(:build_query))
result = ImagedropSchema.multiplex(queries)
else
result = ImagedropSchema.execute(nil, build_query(@operations))
end
render json: result
end
private
def set_current_shop
return if current_shopify_domain.blank?
@current_shop ||= Shop.find_with_shopify_domain(current_shopify_domain)
end
def set_context
@context = {
current_shop: @current_shop,
current_request: request
}
end
...
end
-
Display Products
Shopify Polaris is a style guide that offers a range of resources and building elements like patterns, components that can be imported into your app. The advantage of using Polaris is that you don't have to spend anytime building the UI, getting the colour etc correct - Shopify has already done all the hard work, and we don't need to worry about these details. The recommended way to use Polaris is via React.
I have build a React component that displays all the products with images, and provides search and sort functionalities. We are using useQuery
to make the query via GraphQL to get list of products.
import React, { Component, useState, useEffect } from "react";
...
const PRODUCTS_QUERY = gql`
query {
products {
id
title
images {
id
url
}
}
}
`;
const Shop = () => {
const { data } = useQuery(PRODUCTS_QUERY);
const [products, setProducts] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
const [searchQuery, setSearchQuery] = useState("");
const [selectedCollection, setSelectedCollection] = useState(null);
const [pageSize, setPageSize] = useState(10);
const [sortColumn, setSortColumn] = useState({
path: "title",
order: "asc",
});
const handleDelete = (product, image) => {
const products = [...products];
const index = products.indexOf(product);
products[index] = { ...product };
const images = products[index].images.filter((i) => i.id != image.id);
products[index].images = images;
setProducts(products);
};
const handlePageChange = (page) => {
setCurrentPage(page);
};
const handleCollectionSelect = (collection) => {
setSelectedCollection(collection);
setSearchQuery("");
setCurrentPage(1);
};
const handleSearch = (query) => {
setSelectedCollection(null);
setSearchQuery(query);
setCurrentPage(1);
};
const handleSort = (sortColumn) => {
setSortColumn(sortColumn);
};
const getPageData = () => {
let filtered = products;
if (data) filtered = data['products'];
if (searchQuery)
filtered = filtered.filter((p) =>
p.title.toLowerCase().startsWith(searchQuery.toLowerCase())
);
else if (selectedCollection && selectedCollection.id)
filtered = filtered.filter(
(p) => p.collection_id === selectedCollection.id
);
const sorted = _.orderBy(filtered, [sortColumn.path], [sortColumn.order]);
const paginatedProducts = paginate(sorted, currentPage, pageSize);
return { totalCount: filtered.length, pageData: paginatedProducts };
};
const { totalCount, pageData } = getPageData();
return (
<React.Fragment>
<Navbar />
<Layout>
<Layout.Section secondary>
<Sticky>
<Game />
<Dropzone />
</Sticky>
</Layout.Section>
<Layout.Section>
<div className="row">
<div className="col-10">
<SearchBox value={searchQuery} onChange={handleSearch} />
<ProductsTable
products={pageData}
sortColumn={sortColumn}
onDelete={handleDelete}
onSort={handleSort}
/>
<Paginate
itemsCount={totalCount}
pageSize={pageSize}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</div>
<div className="col-2">
<ToastContainer />
<ListGroup
items={collections}
selectedItem={selectedCollection}
onItemSelect={handleCollectionSelect}
/>
</div>
</div>
</Layout.Section>
</Layout>
</React.Fragment>
);
};
export default Shop;
The Layout
and Sticky
components have been imported from Shopify Polaris.
Next steps
We have successfully imported products from the Shopify Admin API and displayed them on our app.
We used GraphQL to talk to Shopify's APIs and also to communicate between the Rails and React components in our app. In the next blog, we will explore adding a drag-and-drop functionality to the app, and also adding Shopify's billing API to collect payments.
Top comments (4)
Hey Arjun, do you have a sample repo anywhere? It would really help to provide full context. Thank you.
Is it possible to rebuild an existing rails web app frontend initially built without react?
yes.. but will require a lot of changes, if was initially done without React. Have you tried Stimulus? I think that would be an easier approach.. I prefer Stimulus to React too in a Rails app.
great job! , can you please explain to me what does the method "set_operations?"