Small class to simplify the writing and handling of GraphQL queries and mutations for the Shopify Admin API. Comes with built-in retry, token refresh, pagination, error handling, and more!
Queries:
require "shopify_api/graphql/request"
request = ShopifyAPI::GraphQL::Request.new("a-shop", token)
begin
product = request.execute(query, :id => "gid://shopify/Product/123").dig(:data, :product)
p product[:title]
p product[:description_html]
rescue ShopifyAPI::GraphQL::Request::NotFoundError => e
p e
endAnd mutations:
begin
product = request.execute(mutation, :id => "gid://shopify/Product/123", :title => "Foo Hoo!").dig(:data, :product)
rescue ShopifyAPI::GraphQL::Request::UserError => e
p e
endIf your application contains a lot of query and/or mutations it's recommended to organize them by
subclassing ShopifyAPI::GraphQL::Request:
class ShopifyProduct < ShopifyAPI::GraphQL::Request
# Define your queries/mutations
FIND =<<-GQL
query($id: ID!) {
product(id: $id) {
id
title
descriptionHtml
}
}
GQL
UPDATE =<<-GQL
mutation($product: ProductUpdateInput!) {
productUpdate(product: $product) {
product {
id
title
descriptionHtml
}
userErrors {
field
message
}
}
}
GQL
LIST <<-GQL
query($after: String) {
products(first: 25 after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
title
descriptionHtml
}
}
}
}
GQL
def find(id)
execute(FIND, :id => gid::Product(id)).dig(:data, :product)
end
def update(id, changes)
execute(UPDATE, :product => changes.merge(:id => gid::Product(id))).dig(:data, :product_update, :product)
end
def list
paginate(LIST) { |page| yield page.dig(:data, :products, :edges) }
end
endThen use it:
shopify = ShopifyProduct.new("a-shop", token)
begin
product = shopify.find(123)
p product[:id]
p product[:description_html]
rescue ShopifyAPI::GraphQL::Request::NotFoundError => e
warn "Product not found: #{e}"
end
begin
product = shopify.update(123, :description_html => "Something amaaaazing!")
p product[:id]
p product[:description_html]
rescue ShopifyAPI::GraphQL::Request::UserError => e
warn "User errors:"
e.errors { |err| warn err["field"] }
end
begin
shopify.list(123) do |node|
product = node[:node]
p product[:id]
p product[:description_html]
end
rescue ShopifyAPI::GraphQL::Request::NotFoundError => e
warn "Request failed: #{e}"
endSubclasses have access to the following methods:
#execute- Execute a query or mutation with the provided arguments#paginate- Execute a query with pagination; without a block a lazy enumerator (Enumerator::Lazy) is returned#gid- Used for Global ID manipulation (an instance ofTinyGID)#gql- The underlying GraphQL client (an instance ofShopifyAPI::GraphQL::Tiny)
#execute and #paginate also:
- Automatically retry failed or rate-limited requests
- Accept
snake_caseSymbolkeys - Return a
Hashwithsnake_caseSymbolkeys - Raise a
UserErrorwhen a mutation's response containsuserErrors - Raise a
NotFoundErrorwhen a query's result cannot be found
Both of these are small wrappers around the equivalent methods on ShopifyAPI::GraphQL::Tiny.
For more information see its documentation on retries.
With the exception of retry, most defaults can be disabled per instance or per execution:
class ShopifyProduct < ShopifyAPI::GraphQL::Request
def initialize(shop, token)
super(shop, token, :raise_if_not_found => false, :raise_if_user_errors => false, :snake_case => false)
end
def find(gid)
execute(QUERY, :raise_if_not_found => true)
end
endDisabling retry must be done per instance.
Pass the desired version to Request's constructor:
class ShopifyProduct < ShopifyAPI::GraphQL::Request
def initialize(shop, token)
super(shop, token, :version => "2026-01")
end
endShopifyAPI::GraphQL::Request can automatically refresh Shopify's expiring offline access tokens before making a request, and automatically retry once on 401 Unauthorized responses.
To enable auto-refresh, pass the :refresh option:
request = ShopifyAPI::GraphQL::Request.new(shop, token, :refresh => {
:client_id => SHOPIFY_CLIENT_ID,
:client_secret => SHOPIFY_CLIENT_SECRET,
:refresh_token => refresh_token,
:expires_at => expires_at, # Absolute Time or Integer Unix epoch timestamp
:refresh_expires_at => refresh_expires_at, # Absolute Time or Integer Unix epoch timestamp
:synchronize => ->(refresh, shopify_domain) do
shop = Shop.find_by!(:shopify_domain => shopify_domain)
shop.with_lock do
updates = refresh.call # see Multi-Process Concurrency section
now = Time.now.to_i
shop.update!(
:access_token => tokens["access_token"],
:expires_at => now + tokens["expires_in"],
:refresh_token => tokens["refresh_token"],
:refresh_expires_at => now + tokens["refresh_token_expires_in"]
)
end
end
})Because refreshed tokens must be persisted for future requests, :synchronize is required. It is passed a refresh proc and the shop's Shopify domain:
refresh.callreturns aHashwith:access_token,:expires_at,:refresh_token, and:refresh_expires_at. Note that:expires_atand:refresh_expires_atare absolute Unix epoch timestamps (in seconds), not relative duration offsets like Shopify'sexpires_in.- Calling
refresh.callwithout arguments refreshes using the client's current in-memory token state. If you're using multiple processes see Multi-Process Concurrency
In multi-process environments multiple processes may attempt to refresh an expired token simultaneously. To prevent duplicate refresh requests and token invalidation
pass the freshly read storage state to refresh:
:synchronize => ->(refresh, shop) do
shop = Shop.find_by!(:shopify_domain => shopify_domain)
shop.with_lock do
# Pass the latest token state.
# If another worker already refreshed it refresh.call returns the fresh state without hitting Shopify.
new_state = refresh.call(
:access_token => shop.access_token,
:expires_at => shop.expires_at,
:refresh_token => shop.refresh_token,
:refresh_expires_at => shop.refresh_expires_at
)
# save new_state
end
endTo migrate a legacy non-expiring offline access token to an expiring offline access token, call ShopifyAPI::GraphQL::Request::OAuth::AccessToken#migrate:
client = ShopifyAPI::GraphQL::Request::OAuth::AccessToken.new(
SHOPIFY_CLIENT_ID,
SHOPIFY_CLIENT_SECRET,
shop
)
tokens = client.migrate(non_expiring_token)#migrate returns Shopify's token exchange response Hash:
{
"access_token" => "shpca_...",
"scope" => "read_products,write_products",
"expires_in" => 3599,
"refresh_token" => "shprt_...",
"refresh_token_expires_in" => 7775999
}You can then calculate absolute expiration timestamps and persist the tokens for use with Auto-Refreshing Expiring Access Tokens:
now = Time.now.to_i
store.update!(
:access_token => tokens["access_token"],
:expires_at => now + tokens["expires_in"],
:refresh_token => tokens["refresh_token"],
:refresh_expires_at => now + tokens["refresh_token_expires_in"]
)If the token is invalid or cannot be migrated, a ShopifyAPI::GraphQL::Request::OAuth::AccessToken::InvalidTokenError will be raised.
You can also migrate tokens using the provided rake task:
# Migrate a single shop:
bundle exec rake shopify_api:graphql:request:migrate_access_tokens SHOPIFY_API_KEY=X SHOPIFY_API_SECRET=Y SHOP=my-shop TOKEN=shpat_...
# Migrate from a CSV file (with "shop" and "token" columns). HANDLER is required, see below:
bundle exec rake shopify_api:graphql:request:migrate_access_tokens SHOPIFY_API_KEY=X SHOPIFY_API_SECRET=Y FILE=tokens.csv HANDLER=update_token.rbA single shop's tokens are always printed. HANDLER=file.rb is required with FILE (nothing is printed for CSV) and optional with SHOP. Use it to persist the new tokens.
The file is loaded and must define update_token(shop, tokens), which is called with the shop and the return value of #migrate:
# update_token.rb
def update_token(shop, tokens)
Shop.find_by!(:domain => shop).update!(:access_token => tokens["access_token"], ...)
endbundle exec rake shopify_api:graphql:request:migrate_access_tokens SHOPIFY_API_KEY=X SHOPIFY_API_SECRET=Y FILE=tokens.csv HANDLER=update_token.rbA summary of succeeded and failed shops (with the error for each failure) is printed when done. A failure for one shop does not stop the others; the task exits with status 1 if any shop failed.
If a migration request fails or the response is lost, you can safely repeat the #migrate call with the original non-expiring token within
7 days of the initial exchange. Shopify will return the same token pair.
For more information checkout the API docs
- Easy-to-use
- Built-in retry
- Built-in pagination
- Improved exception handling
- You can use
:snake_casehash keys - 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
cp env.template .env and fill-in .env with the missing values. This requires a Shopify store.
- Shopify Dev Tools - Command-line program to assist with the development and/or maintenance of Shopify apps and stores
ShopifyAPI::GraphQL::Tiny- Lightweight, no-nonsense, Shopify GraphQL Admin API client with built-in pagination and retryTinyGID- Build Global ID (gid://) URI strings from scalar values
The gem is available as open source under the terms of the MIT License.
Made by ScreenStaring