Skip to content

Lightweight, no-nonsense, Shopify GraphQL Admin API client with built-in pagination and retry

License

Notifications You must be signed in to change notification settings

ScreenStaring/shopify_api-graphql-tiny

Repository files navigation

ShopifyAPI::GraphQL::Tiny

Lightweight, no-nonsense, Shopify GraphQL Admin API client with built-in pagination and retry

CI

Installation

Add this line to your application's Gemfile:

gem "shopify_api-graphql-tiny"

And then execute:

bundle

Or install it yourself as:

gem install shopify_api-graphql-tiny

Usage

require "shopify_api/graphql/tiny"

gql = ShopifyAPI::GraphQL::Tiny.new("my-shop", token)

# Automatically retried
result = gql.execute(<<-GQL, :id => "gid://shopify/Customer/1283599123")
  query findCustomer($id: ID!) {
    customer(id: $id) {
      id
      tags
      metafields(first: 10 namespace: "foo") {
        edges {
          node {
            id
            key
            value
          }
        }
      }
    }
  }
GQL

customer = result["data"]["customer"]
p customer["tags"]
p customer.dig("metafields", "edges", 0, "node")["value"]

updates = { :id => customer["id"], :tags => customer["tags"] + %w[foo bar] }

# Automatically retried as well
result = gql.execute(<<-GQL, :input => updates)
  mutation customerUpdate($input: CustomerInput!) {
    customerUpdate(input: $input) {
      customer {
        id
      }
      userErrors {
        field
        message
      }
    }
  }
GQL

p result.dig("data", "customerUpdate", "userErrors")

Automatically Retrying Failed Requests

There are 2 types of retries: 1) request is rate-limited by Shopify 2) request fails due to an exception or non-200 HTTP response.

When a request is rate-limited by Shopify retry occurs according to Shopify's throttleStatus

When a request fails due to an exception or non-200 HTTP status a retry will be attempted after an exponential backoff waiting period. This is controlled by ShopifyAPI::GraphQL::Tiny::DEFAULT_BACKOFF_OPTIONS. It contains:

  • :base_delay - 0.5
  • :jitter - true
  • :max_attempts - 10
  • :max_delay - 60
  • :multiplier - 2.0

:max_attempts dictates how many retry attempts will be made for all types of retries.

These can be overridden globally (by assigning to the constant) or per instance:

gql = ShopifyAPI::GraphQL::Tiny.new(shop, token, :max_attempts => 20, :max_delay => 90)

ShopifyAPI::GraphQL::Tiny::DEFAULT_RETRY_ERRORS determines what is retried. It contains HTTP status codes, Shopify GraphQL errors codes, and exception classes. By default it contains:

  • "5XX" - Any HTTP 5XX status
  • "INTERNAL_SERVER_ERROR" - Shopify GraphQL error code
  • "TIMEOUT" - Shopify GraphQL error code
  • EOFError
  • Errno::ECONNABORTED
  • Errno::ECONNREFUSED
  • Errno::ECONNRESET
  • Errno::EHOSTUNREACH
  • Errno::EINVAL
  • Errno::ENETUNREACH
  • Errno::ENOPROTOOPT
  • Errno::ENOTSOCK
  • Errno::EPIPE
  • Errno::ETIMEDOUT
  • Net::HTTPBadResponse
  • Net::HTTPHeaderSyntaxError
  • Net::ProtocolError
  • Net::ReadTimeout
  • OpenSSL::SSL::SSLError
  • SocketError
  • Timeout::Error

These can be overridden globally (by assigning to the constant) or per instance:

# Only retry on 2 errors
gql = ShopifyAPI::GraphQL::Tiny.new(shop, token, :retry => [SystemCallError, "500"])

Disabling Automatic Retry

To disable retries set the :retry option to false:

gql = ShopifyAPI::GraphQL::Tiny.new(shop, token, :retry => false)

Pagination

In addition to built-in request retry ShopifyAPI::GraphQL::Tiny also builds in support for pagination.

Using pagination requires you to include the Shopify PageInfo object in your queries and wrap them in a function that accepts a page/cursor argument.

The pager's #execute is like the non-paginated #execute method and accepts additional, non-pagination query arguments:

gql = ShopifyAPI::GraphQL::Tiny.new("my-shop", token)
pager = gql.paginate
pager.execute(query, :foo => 123)

And it accepts a block which will be passed each page returned by the query:

pager.execute(query, :foo => 123) do |page|
  # do something with each page
end

If a block is not given an Enumerator::Lazy instance is returned that will fetch the next page upon each iteration:

results = pager.execute(query, :foo => 123)
results.each { |page| ... }

after Pagination

To use after pagination, i.e., to paginate forward, your query must:

  • Make the page/cursor argument optional
  • Include PageInfo's hasNextPage and endCursor fields

For example:

FIND_ORDERS = <<-GQL
  query findOrders($after: String) {
    orders(first: 10 after: $after) {
      pageInfo {
        hasNextPage
        endCursor
      }
      edges {
        node {
          id
          email
        }
      }
    }
  }
GQL

pager = gql.paginate  # This is the same as gql.paginate(:after)
pager.execute(FIND_ORDERS) do |page|
  orders = page.dig("data", "orders", "edges")
  orders.each do |order|
    # ...
  end
end

By default it is assumed your GraphQL query uses a variable named $after. You can specify a different name using the :variable option:

pager = gql.paginate(:after, :variable => "yourVariable")

before Pagination

To use before pagination, i.e. to paginate backward, your query must:

  • Make the page/cursor argument required
  • Include the PageInfo's hasPreviousPage and startCursor fields
  • Specify the :before argument to #paginate

For example:

FIND_ORDERS = <<-GQL
  query findOrders($before: String) {
    orders(last: 10 before: $before) {
      pageInfo {
        hasPreviousPage
        startCursor
      }
      edges {
        node {
          id
          email
        }
      }
    }
  }
GQL

pager = gql.paginate(:before)
pager.execute(FIND_ORDERS) do |page|
  # ...
end

By default it is assumed your GraphQL query uses a variable named $before. You can specify a different name using the :variable option:

pager = gql.paginate(:before, :variable => "yourVariable")

Response Pagination Data

By default ShopifyAPI::GraphQL::Tiny will use the first pageInfo block with a next or previous page it finds in the GraphQL response. If necessary you can specify an explicit location for the pageInfo block:

pager = gql.paginate(:after => %w[some path to it])
pager.execute(query) { |page| }

pager = gql.paginate(:after => ->(data) { data.dig("some", "path", "to", "it") })
pager.execute(query) { |page| }

The "data" and "pageInfo" keys are automatically added if not provided.

Why Use This Instead of Shopify's API Client?

  • Easy-to-use
  • Built-in retry
  • Built-in pagination
  • Lightweight

Overall, Shopify's API client is bloated trash that will give you development headaches and long-term maintenance nightmares.

We used to use it, staring way back in 2015, but eventually had to pivot away from their Ruby libraries due to developer frustration and high maintenance cost (and don't get us started on the ShopifyApp gem!@#).

For more information see: Shopify/shopify-api-ruby#1181

Testing

cp env.template .env and fill-in .env with the missing values. This requires a Shopify store.

To elicit a request that will be rate-limited by Shopify run following Rake task:

bundle exec rake rate_limit SHOPIFY_DOMAIN=your-domain SHOPIFY_TOKEN=your-token

See Also

  • ShopifyAPI::GraphQL::Request - A higher-level wrapper around this class with improved exception handling and :snake_case hash key conversion
  • Shopify Dev Tools - Command-line program to assist with the development and/or maintenance of Shopify apps and stores
  • Shopify ID Export - Dump Shopify product and variant IDs —along with other identifiers— to a CSV or JSON file
  • TinyGID - Build Global ID (gid://) URI strings from scalar values

License

The gem is available as open source under the terms of the MIT License.


Made by ScreenStaring

About

Lightweight, no-nonsense, Shopify GraphQL Admin API client with built-in pagination and retry

Topics

Resources

License

Stars

Watchers

Forks