DEV Community

loading...
Cover image for Building a Shopify App with Rails, React and GraphQL

Building a Shopify App with Rails, React and GraphQL

arjunrajkumar profile image Arjun Rajkumar Updated on ・7 min read

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:

  1. Connecting to Shopify
  2. Retrieving product information from Shopify
  3. Storing the products in your database
  4. 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

  1. GraphQL only provides the data you ask for, reducing bandwidth and overhead, and usually improves the speed of your app.
  2. Unlike REST APIs, which uses multiple endpoints to return large sets of data, GraphQL uses a single endpoint.
  3. 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.

Alt Text

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.

Discussion (3)

pic
Editor guide
Collapse
owalls profile image
owalls

Is it possible to rebuild an existing rails web app frontend initially built without react?

Collapse
arjunrajkumar profile image
Arjun Rajkumar Author

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.

Collapse
asl331 profile image
asl331 • Edited

great job! , can you please explain to me what does the method "set_operations?"