shopify_api-graphql-request 0.0.2 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 392d3a9629abbbe7f7c768b5d2af6597f90dfc5728c4c55d2908d41730b66497
4
- data.tar.gz: 790ba7b6a275ef632d0a43b86e4b141065ebd2a6bde41bc5b90bff7961018b96
3
+ metadata.gz: 8693e4abd154bf4592d8582f4f00c542a22440a47554529cabca27a2acca9102
4
+ data.tar.gz: 2baa318c0578781d9419bb7916d26eeb59a09783d4ccaddfe577d485a9a6a3da
5
5
  SHA512:
6
- metadata.gz: 65b742996ac8a08a6e878424c0f293ccc3d899cd55a7a562de76eccb559d63163738faf0a3dfe1cc6f87e7cb9104cd04473c549b5f802e86f2b336973d7001d0
7
- data.tar.gz: d8644370482c62f2aa95d22eaf7e59e406d7e32754b6ce48d70a0f0ad5cc0753d38ca13c7770f0dfaddbfd9ef4edb6cd1a0f78dd48d14aaa20f108f1b915b84f
6
+ metadata.gz: 2ea1da8888f62de6903138ae9f116578f155e044a6375ee2646f8ae416c2b4ba53c903e7adc9922fa85d02d8d5f6cb48a9244251d78d9650d3f6a85019127177
7
+ data.tar.gz: dec264b7bcdf723b6e0da2ed3c5d7554f645797e5f4ace298effb046b2aca1da7077b977e2b06d46c82ce840af158c605e470787ee6d1b10a98b916929fc7b05
data/.env.template CHANGED
@@ -9,6 +9,13 @@
9
9
  SHOPIFY_DOMAIN=
10
10
  SHOPIFY_TOKEN=
11
11
 
12
+ # App credentials test test OAuth::AccessToken
13
+ SHOPIFY_CLIENT_API_KEY=
14
+ SHOPIFY_CLIENT_API_SECRET=
15
+
16
+ # Refresh token needed to test OAuth::AccessToken#refresh
17
+ SHOPIFY_REFRESH_TOKEN=
18
+
12
19
  # Will result in the creation of a metadafield on the customer. write_customers permission required
13
20
  SHOPIFY_CUSTOMER_ID=
14
21
 
data/Changes CHANGED
@@ -1,3 +1,13 @@
1
+ v0.1.1 2026-11-11
2
+ --------------------
3
+ Improve robustness of token migration task when using a spreadsheet
4
+
5
+ v0.1.0 2026-11-10
6
+ --------------------
7
+ Add OAuth::AccessToken to support Shopify expiring tokens
8
+ Add :refresh option to Request to automatically refresh expiring tokens
9
+ Add shopify_api:graphql:request:migrate_access_tokens Rake task for non-expiring token migration
10
+
1
11
  v0.0.2 2026-04-19
2
12
  --------------------
3
13
  Require TinyGID >= 0.1.2. This version allows one to call #parse and #to_s methods on #gid.
data/README.md CHANGED
@@ -1,7 +1,9 @@
1
1
  # ShopifyAPI::GraphQL::Request
2
2
 
3
+ [![CI](https://github.com/ScreenStaring/shopify_api-graphql-request/actions/workflows/main.yml/badge.svg)](https://github.com/ScreenStaring/shopify_api-graphql-request/actions/workflows/main.yml)
4
+
3
5
  Small class to simplify the writing and handling of GraphQL queries and mutations for the Shopify Admin API.
4
- Comes with built-in retry, pagination, error handling, and more!
6
+ Comes with built-in retry, token refresh, pagination, error handling, and more!
5
7
 
6
8
  ## Usage
7
9
 
@@ -174,6 +176,141 @@ class ShopifyProduct < ShopifyAPI::GraphQL::Request
174
176
  end
175
177
  ```
176
178
 
179
+ ### Auto-Refreshing Expiring Access Tokens
180
+
181
+ `ShopifyAPI::GraphQL::Request` can automatically refresh Shopify's expiring offline access tokens before making a request, and automatically retry once on `401 Unauthorized` responses.
182
+
183
+ To enable auto-refresh, pass the `:refresh` option:
184
+
185
+ ```rb
186
+ request = ShopifyAPI::GraphQL::Request.new(shop, token, :refresh => {
187
+ :client_id => SHOPIFY_CLIENT_ID,
188
+ :client_secret => SHOPIFY_CLIENT_SECRET,
189
+ :refresh_token => refresh_token,
190
+ :expires_at => expires_at, # Absolute Time or Integer Unix epoch timestamp
191
+ :refresh_expires_at => refresh_expires_at, # Absolute Time or Integer Unix epoch timestamp
192
+ :synchronize => ->(refresh, shopify_domain) do
193
+ shop = Shop.find_by!(:shopify_domain => shopify_domain)
194
+
195
+ shop.with_lock do
196
+ updates = refresh.call # see Multi-Process Concurrency section
197
+ now = Time.now.to_i
198
+
199
+ shop.update!(
200
+ :access_token => tokens["access_token"],
201
+ :expires_at => now + tokens["expires_in"],
202
+ :refresh_token => tokens["refresh_token"],
203
+ :refresh_expires_at => now + tokens["refresh_token_expires_in"]
204
+ )
205
+ end
206
+ end
207
+ })
208
+ ```
209
+
210
+ #### The `:synchronize` Callback
211
+
212
+ Because refreshed tokens must be persisted for future requests, `:synchronize` is required. It is passed a `refresh` proc and the shop's Shopify domain:
213
+
214
+ * `refresh.call` returns a `Hash` with `:access_token`, `:expires_at`, `:refresh_token`, and `:refresh_expires_at`. Note that `:expires_at` and `:refresh_expires_at` are absolute Unix epoch timestamps (in seconds), not relative duration offsets like Shopify's `expires_in`.
215
+ * Calling `refresh.call` without arguments refreshes using the client's **current in-memory token state**. If you're using multiple processes see [Multi-Process Concurrency](#multi-process-concurrency)
216
+
217
+ #### Multi-Process Concurrency
218
+
219
+ In multi-process environments multiple processes may attempt to refresh an expired token simultaneously. To prevent duplicate refresh requests and token invalidation
220
+ pass the freshly read storage state to `refresh`:
221
+
222
+ ```rb
223
+ :synchronize => ->(refresh, shop) do
224
+ shop = Shop.find_by!(:shopify_domain => shopify_domain)
225
+ shop.with_lock do
226
+ # Pass the latest token state.
227
+ # If another worker already refreshed it refresh.call returns the fresh state without hitting Shopify.
228
+ new_state = refresh.call(
229
+ :access_token => shop.access_token,
230
+ :expires_at => shop.expires_at,
231
+ :refresh_token => shop.refresh_token,
232
+ :refresh_expires_at => shop.refresh_expires_at
233
+ )
234
+
235
+ # save new_state
236
+ end
237
+ end
238
+ ```
239
+
240
+ ### Migrating Non-Expiring Access Tokens
241
+
242
+ To migrate a legacy non-expiring offline access token to an expiring offline access token, call `ShopifyAPI::GraphQL::Request::OAuth::AccessToken#migrate`:
243
+
244
+ ```rb
245
+ client = ShopifyAPI::GraphQL::Request::OAuth::AccessToken.new(
246
+ SHOPIFY_CLIENT_ID,
247
+ SHOPIFY_CLIENT_SECRET,
248
+ shop
249
+ )
250
+
251
+ tokens = client.migrate(non_expiring_token)
252
+ ```
253
+
254
+ `#migrate` returns Shopify's token exchange response `Hash`:
255
+
256
+ ```rb
257
+ {
258
+ "access_token" => "shpca_...",
259
+ "scope" => "read_products,write_products",
260
+ "expires_in" => 3599,
261
+ "refresh_token" => "shprt_...",
262
+ "refresh_token_expires_in" => 7775999
263
+ }
264
+ ```
265
+
266
+ You can then calculate absolute expiration timestamps and persist the tokens for use with [Auto-Refreshing Expiring Access Tokens](#auto-refreshing-expiring-access-tokens):
267
+
268
+ ```rb
269
+ now = Time.now.to_i
270
+
271
+ store.update!(
272
+ :access_token => tokens["access_token"],
273
+ :expires_at => now + tokens["expires_in"],
274
+ :refresh_token => tokens["refresh_token"],
275
+ :refresh_expires_at => now + tokens["refresh_token_expires_in"]
276
+ )
277
+ ```
278
+
279
+ If the token is invalid or cannot be migrated, a `ShopifyAPI::GraphQL::Request::OAuth::AccessToken::InvalidTokenError` will be raised.
280
+
281
+ #### Rake Task
282
+
283
+ You can also migrate tokens using the provided rake task:
284
+
285
+ ```sh
286
+ # Migrate a single shop:
287
+ bundle exec rake shopify_api:graphql:request:migrate_access_tokens SHOPIFY_API_KEY=X SHOPIFY_API_SECRET=Y SHOP=my-shop TOKEN=shpat_...
288
+
289
+ # Migrate from a CSV file (with "shop" and "token" columns). HANDLER is required, see below:
290
+ bundle exec rake shopify_api:graphql:request:migrate_access_tokens SHOPIFY_API_KEY=X SHOPIFY_API_SECRET=Y FILE=tokens.csv HANDLER=update_token.rb
291
+ ```
292
+
293
+ A 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.
294
+ The file is `load`ed and must define `update_token(shop, tokens)`, which is called with the shop and the return value of `#migrate`:
295
+
296
+ ```rb
297
+ # update_token.rb
298
+ def update_token(shop, tokens)
299
+ Shop.find_by!(:domain => shop).update!(:access_token => tokens["access_token"], ...)
300
+ end
301
+ ```
302
+
303
+ ```sh
304
+ bundle exec rake shopify_api:graphql:request:migrate_access_tokens SHOPIFY_API_KEY=X SHOPIFY_API_SECRET=Y FILE=tokens.csv HANDLER=update_token.rb
305
+ ```
306
+
307
+ A 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.
308
+
309
+ #### Recovering a Lost Response
310
+
311
+ If a migration request fails or the response is lost, you can safely repeat the `#migrate` call with the original non-expiring token within
312
+ 7 days of the initial exchange. Shopify will return the same token pair.
313
+
177
314
  ### More Info
178
315
 
179
316
  For more information checkout [the API docs](https://rdoc.info/gems/shopify_api-graphql-request)
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ require "shopify_api/graphql/request/version"
7
+
8
+ module ShopifyAPI
9
+ module GraphQL
10
+ class Request
11
+ module OAuth
12
+ ##
13
+ # Shopify OAuth access token operations
14
+ #
15
+ class AccessToken
16
+ InvalidTokenError = Class.new(Error)
17
+ InvalidRefreshTokenError = Class.new(Error)
18
+
19
+ TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
20
+ OFFLINE_TOKEN_TYPE = "urn:shopify:params:oauth:token-type:offline-access-token"
21
+ REFRESH_TOKEN_GRANT_TYPE = "refresh_token"
22
+
23
+ TOKEN_ENDPOINT = "https://%s/admin/oauth/access_token"
24
+
25
+ USER_AGENT = "ShopifyAPI::GraphQL::Request v#{VERSION} (Ruby v#{RUBY_VERSION})"
26
+
27
+ SHOPIFY_DOMAIN = ".myshopify.com"
28
+
29
+ ##
30
+ # === Arguments
31
+ #
32
+ # [client_id (String)] App's client ID
33
+ # [client_secret (String)] App's client secret
34
+ # [shop (String)] Shopify domain to make requests against
35
+ # [options (Hash)] Client options. Optional.
36
+ #
37
+ # === Options
38
+ #
39
+ # [:open_timeout (Numeric)] Number of seconds to wait for the connection to open. Defaults to Net::HTTP's default.
40
+ # [:read_timeout (Numeric)] Number of seconds to wait for a response. Defaults to Net::HTTP's default.
41
+ # [:debug (Boolean|IO)] Output the HTTP request/response to +STDERR+ or to its value if it's an +IO+. Defaults to +false+.
42
+ #
43
+ # === Errors
44
+ #
45
+ # ArgumentError if no +client_id+, +client_secret+, or +shop+ is provided.
46
+ #
47
+ def initialize(client_id, client_secret, shop, options = nil)
48
+ raise ArgumentError, "client_id required" if client_id.to_s.strip.empty?
49
+ raise ArgumentError, "client_secret required" if client_secret.to_s.strip.empty?
50
+ raise ArgumentError, "shop required" if shop.to_s.strip.empty?
51
+
52
+ @client_id = client_id
53
+ @client_secret = client_secret
54
+ @domain = shopify_domain(shop)
55
+
56
+ options ||= {}
57
+
58
+ @open_timeout = options[:open_timeout]
59
+ @read_timeout = options[:read_timeout]
60
+
61
+ if options[:debug]
62
+ @debug = options[:debug].is_a?(IO) ? options[:debug] : $stderr
63
+ end
64
+
65
+ @endpoint = URI(sprintf(TOKEN_ENDPOINT, @domain))
66
+ end
67
+
68
+ ##
69
+ # Migrate a legacy non-expiring offline access token to an expiring offline access token
70
+ #
71
+ # === Arguments
72
+ #
73
+ # [non_expiring_token (String)] Existing non-expiring offline access token to migrate
74
+ #
75
+ # === Returns
76
+ #
77
+ # [Hash] The token response. Unmodified.
78
+ #
79
+ # === Errors
80
+ #
81
+ # ArgumentError, InvalidTokenError, Error
82
+ #
83
+ def migrate(non_expiring_token)
84
+ raise ArgumentError, "token required" if non_expiring_token.to_s.strip.empty?
85
+
86
+ request(
87
+ :client_id => @client_id,
88
+ :client_secret => @client_secret,
89
+ :grant_type => TOKEN_EXCHANGE_GRANT_TYPE,
90
+ :subject_token => non_expiring_token,
91
+ :subject_token_type => OFFLINE_TOKEN_TYPE,
92
+ :requested_token_type => OFFLINE_TOKEN_TYPE,
93
+ :expiring => 1
94
+ )
95
+ end
96
+
97
+ ##
98
+ # Refresh an expiring offline access token
99
+ #
100
+ # === Arguments
101
+ #
102
+ # [refresh_token (String)] Refresh token to use
103
+ #
104
+ # === Returns
105
+ #
106
+ # [Hash] The token response. Unmodified.
107
+ #
108
+ # === Errors
109
+ #
110
+ # ArgumentError, InvalidRefreshTokenError, Error
111
+ #
112
+ def refresh(refresh_token)
113
+ raise ArgumentError, "refresh token required" if refresh_token.to_s.strip.empty?
114
+
115
+ request(
116
+ :client_id => @client_id,
117
+ :client_secret => @client_secret,
118
+ :grant_type => REFRESH_TOKEN_GRANT_TYPE,
119
+ :refresh_token => refresh_token
120
+ )
121
+ end
122
+
123
+ private
124
+
125
+ def request(params)
126
+ response = post(params)
127
+ body = parse_body(response)
128
+
129
+ handle_error(response, body) unless response.is_a?(Net::HTTPSuccess)
130
+
131
+ body
132
+ end
133
+
134
+ def post(params)
135
+ http = Net::HTTP.new(@endpoint.host, @endpoint.port)
136
+ http.use_ssl = true
137
+ http.open_timeout = @open_timeout if @open_timeout
138
+ http.read_timeout = @read_timeout if @read_timeout
139
+ http.set_debug_output(@debug) if @debug
140
+
141
+ post = Net::HTTP::Post.new(@endpoint.path)
142
+ post.body = URI.encode_www_form(params)
143
+ post["Content-Type"] = "application/x-www-form-urlencoded"
144
+ post["Accept"] = "application/json"
145
+ post["User-Agent"] = USER_AGENT
146
+
147
+ http.start { |connection| connection.request(post) }
148
+ end
149
+
150
+ def parse_body(response)
151
+ return if response.body.nil? || response.body.empty?
152
+ return response.body unless response["Content-Type"].to_s.include?("json")
153
+
154
+ JSON.parse(response.body)
155
+ rescue JSON::ParserError => e
156
+ raise Error, "failed to parse JSON response: #{e.message}"
157
+ end
158
+
159
+ def handle_error(response, body)
160
+ error = body["error"] if body.is_a?(Hash)
161
+ message = error_message(body)
162
+
163
+ if response.code == "400" && (error == "invalid_subject_token" || error == "invalid_requested_token_type")
164
+ raise InvalidTokenError, "failed to migrate access token for #{@domain}: #{message}"
165
+ # RFC 6749 §5.2 specifies HTTP 400 for invalid_grant, but Shopify returns HTTP 401
166
+ # when a refresh token is inactive, expired, or already rotated. Check both to be safe.
167
+ elsif (response.code == "400" || response.code == "401") && error == "invalid_grant"
168
+ raise InvalidRefreshTokenError, "failed to refresh access token for #{@domain}: #{message}"
169
+ else
170
+ raise Error, "failed to request access token for #{@domain}: #{message} (HTTP #{response.code})"
171
+ end
172
+ end
173
+
174
+ def error_message(body)
175
+ return body unless body.is_a?(Hash)
176
+
177
+ body["error_description"] || body["error"] || body.to_s
178
+ end
179
+
180
+ def shopify_domain(host)
181
+ domain = host.sub(%r{\Ahttps?://}i, "")
182
+ domain << SHOPIFY_DOMAIN unless domain.end_with?(SHOPIFY_DOMAIN)
183
+ domain
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "pp"
5
+
6
+ require "shopify_api/graphql/request/oauth/access_token"
7
+
8
+ module ShopifyAPI
9
+ module GraphQL
10
+ class Request
11
+ module OAuth
12
+ ##
13
+ # Migrates legacy non-expiring offline access tokens to expiring ones for
14
+ # a single shop or for many shops listed in a CSV file.
15
+ #
16
+ # Backs the +shopify_api:graphql:request:migrate_access_tokens+ rake task.
17
+ #
18
+ class AccessTokenMigrator
19
+ HANDLER_METHOD = :update_token
20
+
21
+ ##
22
+ # === Arguments
23
+ #
24
+ # [client_id (String)] Shopify app client ID
25
+ # [client_secret (String)] Shopify app client secret
26
+ # [options (Hash)]
27
+ #
28
+ # === Options
29
+ #
30
+ # [:shop (String)] Shop to migrate.
31
+ # [:token (String)] Non-expiring token for +:shop+
32
+ # [:file (String)] CSV file with +shop+ and +token+ columns. Ignored if +:shop+ is given. Requires +:handler+.
33
+ # [:handler (String)] Ruby file to +load+. It must define +update_token(shop, tokens)+, which is called
34
+ # with the shop and the return value of AccessToken#migrate.
35
+ # [:out (IO)] Where to write results. Defaults to +$stdout+.
36
+ #
37
+ # === Errors
38
+ #
39
+ # ArgumentError
40
+ #
41
+ def initialize(client_id, client_secret, options = {})
42
+ @client_id = client_id
43
+ @client_secret = client_secret
44
+ @shop = nil_if_blank(options[:shop])
45
+ @token = nil_if_blank(options[:token])
46
+ @file = nil_if_blank(options[:file])
47
+ @handler = nil_if_blank(options[:handler])
48
+ @out = options[:out] || $stdout
49
+
50
+ raise ArgumentError, "client_id required" if nil_if_blank(client_id).nil?
51
+ raise ArgumentError, "client_secret required" if nil_if_blank(client_secret).nil?
52
+ raise ArgumentError, "shop or file required" unless @shop || @file
53
+ raise ArgumentError, "handler required when using file" if !@shop && !@handler
54
+ end
55
+
56
+ ##
57
+ # Perform the migration(s).
58
+ #
59
+ # === Returns
60
+ #
61
+ # [Array<Hash>] One status per shop, in input order:
62
+ # +{ :shop => String, :result => Hash, :error => nil }+ or
63
+ # +{ :shop => String, :result => nil, :error => String }+.
64
+ # Per-shop errors (+StandardError+, including those raised by the handler) are
65
+ # rescued so one failure does not stop the rest.
66
+ #
67
+ # === Errors
68
+ #
69
+ # ArgumentError for invalid input (before any shop is processed)
70
+ #
71
+ def run
72
+ pairs = @shop ? [single_shop_and_token] : rows
73
+ load_handler if @handler
74
+
75
+ pairs.map { |shop, token| migrate(shop, token) }
76
+ end
77
+
78
+ private
79
+
80
+ def nil_if_blank(value)
81
+ value unless value.to_s.strip.empty?
82
+ end
83
+
84
+ def load_handler
85
+ raise ArgumentError, "Handler not found: #{@handler}" unless File.file?(@handler)
86
+
87
+ load File.expand_path(@handler)
88
+ return if respond_to?(HANDLER_METHOD, true)
89
+
90
+ raise ArgumentError, "Handler must define #{HANDLER_METHOD}: #{@handler}"
91
+ end
92
+
93
+ def single_shop_and_token
94
+ raise ArgumentError, "token required" unless @token
95
+
96
+ [@shop, @token]
97
+ end
98
+
99
+ def rows
100
+ raise ArgumentError, "File not found: #{@file}" unless File.file?(@file)
101
+
102
+ table = CSV.read(@file, :headers => true, :skip_blanks => true, :converters => ->(value) { value&.strip }, :header_converters => ->(h) { h.to_s.strip.downcase })
103
+ unless table.headers.include?("shop") && table.headers.include?("token")
104
+ raise ArgumentError, "CSV must contain 'shop' and 'token' columns"
105
+ end
106
+
107
+ table.map { |row| [row["shop"], row["token"]] }.reject { |shop, token| shop.nil? && token.nil? }
108
+ end
109
+
110
+ def migrate(shop, token)
111
+ client = AccessToken.new(@client_id, @client_secret, shop)
112
+ result = client.migrate(token)
113
+
114
+ @out.puts result.pretty_inspect if @shop
115
+
116
+ call_handler(result, shop) if @handler
117
+
118
+ { :shop => shop, :result => result, :error => nil }
119
+ rescue StandardError => e
120
+ { :shop => shop, :result => nil, :error => "#{e.class}: #{e.message}" }
121
+ end
122
+
123
+ def call_handler(result, shop)
124
+ send(HANDLER_METHOD, shop, result)
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyAPI
4
+ module GraphQL
5
+ class Railtie < Rails::Railtie
6
+ rake_tasks do
7
+ load File.expand_path("../../../tasks/shopify.rake", __dir__)
8
+
9
+ # Task is also usable outside Rails, so only add the dep here
10
+ Rake::Task["shopify_api:graphql:request:migrate_access_tokens"].enhance([:environment])
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ load File.expand_path("../../../tasks/shopify.rake", __dir__)
@@ -3,7 +3,7 @@
3
3
  module ShopifyAPI
4
4
  module GraphQL
5
5
  class Request
6
- VERSION = "0.0.2"
6
+ VERSION = "0.1.1"
7
7
  end
8
8
  end
9
9
  end
@@ -13,6 +13,12 @@ module ShopifyAPI
13
13
  #
14
14
  class Request
15
15
  Error = Class.new(StandardError)
16
+ RefreshNotCalledError = Class.new(Error)
17
+
18
+ # Smallest plausible Unix epoch timestamp (Sep 2001). Used to guard against callers
19
+ # mistakenly passing relative duration offsets (e.g., 3600 or Shopify's `expires_in`)
20
+ # instead of an absolute point in time.
21
+ MIN_EPOCH_TIMESTAMP = 1_000_000_000
16
22
 
17
23
  class UserError < Error
18
24
  attr_reader :errors
@@ -60,6 +66,21 @@ module ShopifyAPI
60
66
  # [:raise_if_not_found (Boolean)] If +true+ raise a NotFoundError if the requested record is not found. Defaults to +true+.
61
67
  # [:raise_if_user_errors (Boolean)] If +true+ raise a UserError if the mutation resulted in user errors. Defaults to +true+.
62
68
  # [:snake_case (Boolean)] Convert response `Hash` keys to +snake_case+ symbols. Defaults to +true+.
69
+ # [:refresh (Hash)] Auto-refresh expiring access tokens. Requires +:client_id+, +:client_secret+,
70
+ # +:expires_at+, +:refresh_token+, +:refresh_expires_at+, and +:synchronize+.
71
+ # +:expires_at+ and +:refresh_expires_at+ must be absolute timestamps (+Time+s or Integer Unix epoch timestamps in seconds),
72
+ # not relative duration offsets (e.g. Shopify's +expires_in+).
73
+ #
74
+ # +:synchronize+ is a Proc called with a refresh Proc and the shop. It should persist the new token
75
+ # state returned by the refresh Proc:
76
+ #
77
+ # :synchronize => ->(refresh, shop) do
78
+ # store.update!(refresh.call)
79
+ # end
80
+ #
81
+ # Calling +refresh.call+ without arguments uses the client's current in-memory token state.
82
+ # In multi-process environments, pass the freshly read token state from storage (e.g. +refresh.call(db_state)+)
83
+ # under a lock to prevent concurrent workers from performing duplicate refreshes.
63
84
  #
64
85
  # Additional options: those accepted by {ShopifyAPI::GraphQL::Tiny}[https://rdoc.info/gems/shopify_api-graphql-tiny]
65
86
  #
@@ -70,9 +91,30 @@ module ShopifyAPI
70
91
  @options[name] = true unless @options.include?(name)
71
92
  end
72
93
 
94
+ @shop = shop
73
95
  @gql = ShopifyAPI::GraphQL::Tiny.new(shop, token, @options)
74
96
 
75
97
  @gid = TinyGID.new("shopify")
98
+
99
+ refresh = @options[:refresh]
100
+ return unless refresh
101
+
102
+ raise ArgumentError, ":refresh must be a Hash" unless refresh.is_a?(Hash)
103
+
104
+ missing = [:client_id, :client_secret, :expires_at, :refresh_token, :refresh_expires_at, :synchronize].reject { |name| refresh.include?(name) }
105
+ raise ArgumentError, ":refresh requires #{missing.join(", ")}" if missing.any?
106
+
107
+ validate_timestamp(:expires_at, refresh[:expires_at])
108
+ validate_timestamp(:refresh_expires_at, refresh[:refresh_expires_at]) if refresh[:refresh_expires_at]
109
+
110
+ @oauth = OAuth::AccessToken.new(refresh[:client_id], refresh[:client_secret], shop, @options)
111
+ @synchronize = refresh[:synchronize]
112
+ @token_state = {
113
+ :access_token => token,
114
+ :expires_at => refresh[:expires_at],
115
+ :refresh_token => refresh[:refresh_token],
116
+ :refresh_expires_at => refresh[:refresh_expires_at]
117
+ }
76
118
  end
77
119
 
78
120
  ##
@@ -100,6 +142,7 @@ module ShopifyAPI
100
142
  # === Errors
101
143
  #
102
144
  # * ArgumentError
145
+ # * ShopifyAPI::GraphQL::Request::RefreshNotCalledError - if :synchronize did not invoke refresh
103
146
  # * ShopifyAPI::GraphQL::Request::UserError - the a mutation contains user errors
104
147
  # * ShopifyAPI::GraphQL::Request::NotFoundError - if the query cannot be find the given object
105
148
  # * ShopifyAPI::GraphQL::Tiny::ConnectionError
@@ -111,8 +154,16 @@ module ShopifyAPI
111
154
  options = @options.merge(options || {})
112
155
 
113
156
  variables = camelize_keys(variables) if options[:snake_case]
157
+ ensure_fresh_token
158
+
159
+ begin
160
+ data = gql.execute(query, variables)
161
+ rescue ShopifyAPI::GraphQL::Tiny::HTTPError => error
162
+ raise unless refreshable? && error.code == "401"
114
163
 
115
- data = gql.execute(query, variables)
164
+ refresh_after_401
165
+ data = gql.execute(query, variables)
166
+ end
116
167
 
117
168
  raise_if_not_found(data, variables) if options[:raise_if_not_found]
118
169
  raise_if_user_errors(data) if options[:raise_if_user_errors]
@@ -143,20 +194,28 @@ module ShopifyAPI
143
194
  options = @options.merge(options || {})
144
195
 
145
196
  variables = camelize_keys(variables) if options[:snake_case]
197
+ ensure_fresh_token
146
198
 
147
- pager = gql.paginate
148
- # execute() returns a lazy enumerator so we're not loading everything now.
149
- pages = pager.execute(query, variables).map do |page|
150
- raise_if_not_found(page, variables) if options[:raise_if_not_found]
151
- raise_if_user_errors(page) if options[:raise_if_user_errors]
199
+ begin
200
+ pager = gql.paginate
201
+ # execute() returns a lazy enumerator so we're not loading everything now.
202
+ pages = pager.execute(query, variables).map do |page|
203
+ raise_if_not_found(page, variables) if options[:raise_if_not_found]
204
+ raise_if_user_errors(page) if options[:raise_if_user_errors]
152
205
 
153
- page = snake_case_keys(page) if options[:snake_case]
154
- page
155
- end
206
+ page = snake_case_keys(page) if options[:snake_case]
207
+ page
208
+ end
156
209
 
157
- return pages unless block_given?
210
+ return pages unless block_given?
158
211
 
159
- pages.each { |page| yield page }
212
+ pages.each { |page| yield page }
213
+ rescue ShopifyAPI::GraphQL::Tiny::HTTPError => error
214
+ raise unless refreshable? && error.code == "401"
215
+
216
+ refresh_after_401
217
+ retry
218
+ end
160
219
 
161
220
  nil
162
221
  end
@@ -191,6 +250,92 @@ module ShopifyAPI
191
250
 
192
251
  private
193
252
 
253
+ def ensure_fresh_token
254
+ return unless refreshable?
255
+ return unless token_expired?(@token_state)
256
+
257
+ synchronize(method(:refresh_tokens))
258
+ end
259
+
260
+ def refreshable?
261
+ @oauth && @synchronize
262
+ end
263
+
264
+ def token_expired?(state)
265
+ expires_at = state[:expires_at]
266
+ expires_at && to_time(expires_at) <= Time.now
267
+ end
268
+
269
+ def to_time(value)
270
+ return value.to_time if value.respond_to?(:to_time)
271
+
272
+ validate_timestamp(:expires_at, value)
273
+ Time.at(value)
274
+ end
275
+
276
+ # Ensures timestamp values are absolute points in time (Time instances or Unix epoch timestamps)
277
+ # rather than relative offsets (e.g., passing `expires_in: 3600`), which would otherwise evaluate
278
+ # to 1970 and cause continuous refresh loops.
279
+ def validate_timestamp(name, value)
280
+ return if value.respond_to?(:to_time)
281
+
282
+ if value.is_a?(Numeric)
283
+ return if value >= MIN_EPOCH_TIMESTAMP
284
+
285
+ raise ArgumentError, ":#{name} must be a Time or modern Unix epoch timestamp in seconds, not a relative offset (got #{value})"
286
+ end
287
+
288
+ raise ArgumentError, ":#{name} must be a Time or Integer Unix epoch timestamp (got #{value.inspect})"
289
+ end
290
+
291
+ def refresh_tokens(state = @token_state)
292
+ state ||= @token_state
293
+ if token_expired?(state)
294
+ @token_state = normalize_tokens(@oauth.refresh(state[:refresh_token]))
295
+ else
296
+ @token_state = state
297
+ end
298
+ end
299
+
300
+ def refresh_stale_tokens(state = @token_state)
301
+ state ||= @token_state
302
+ if state.equal?(@token_state) || state[:access_token] == @token_state[:access_token]
303
+ @token_state = normalize_tokens(@oauth.refresh(state[:refresh_token]))
304
+ else
305
+ @token_state = state
306
+ end
307
+ end
308
+
309
+ def refresh_after_401
310
+ synchronize(method(:refresh_stale_tokens))
311
+ end
312
+
313
+ def synchronize(refresh)
314
+ invoked = false
315
+ callable = ->(*args, &block) do
316
+ invoked = true
317
+ refresh.call(*args, &block)
318
+ end
319
+
320
+ result = @synchronize.call(callable, @shop)
321
+ raise RefreshNotCalledError, ":synchronize did not call refresh" unless invoked
322
+
323
+ @token_state = result if result.is_a?(Hash) && result.key?(:access_token)
324
+ @gql = ShopifyAPI::GraphQL::Tiny.new(@shop, @token_state[:access_token], @options)
325
+ @token_state
326
+ end
327
+
328
+ def normalize_tokens(tokens)
329
+ now = Time.now.to_i
330
+
331
+ {
332
+ :access_token => tokens["access_token"],
333
+ :expires_at => now + tokens["expires_in"],
334
+ :refresh_token => tokens["refresh_token"],
335
+ :refresh_expires_at => tokens["refresh_token_expires_in"] ? now + tokens["refresh_token_expires_in"] : nil
336
+ }
337
+ end
338
+
194
339
  def transform_keys(object, &transformer)
195
340
  case object
196
341
  when Hash
@@ -206,3 +351,6 @@ module ShopifyAPI
206
351
  end
207
352
  end
208
353
  end
354
+
355
+ require "shopify_api/graphql/request/oauth/access_token"
356
+ require "shopify_api/graphql/request/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shopify_api/graphql/request"
4
+ require "shopify_api/graphql/request/oauth/access_token_migrator"
5
+
6
+ namespace :shopify_api do
7
+ namespace :graphql do
8
+ namespace :request do
9
+ desc "Migrate legacy non-expiring Shopify offline access tokens to expiring offline access tokens"
10
+ task :migrate_access_tokens do
11
+ client_id = ENV["SHOPIFY_API_KEY"].to_s.strip
12
+ client_secret = ENV["SHOPIFY_API_SECRET"].to_s.strip
13
+
14
+ abort "SHOPIFY_API_KEY required" if client_id.empty?
15
+ abort "SHOPIFY_API_SECRET required" if client_secret.empty?
16
+
17
+ begin
18
+ statuses = ShopifyAPI::GraphQL::Request::OAuth::AccessTokenMigrator.new(
19
+ client_id,
20
+ client_secret,
21
+ :shop => ENV["SHOP"],
22
+ :token => ENV["TOKEN"],
23
+ :file => ENV["FILE"],
24
+ :handler => ENV["HANDLER"]
25
+ ).run
26
+ rescue ArgumentError => e
27
+ abort e.message
28
+ end
29
+
30
+ failed, succeeded = statuses.partition { |status| status[:error] }
31
+
32
+ puts "Succeeded (#{succeeded.size}):"
33
+ succeeded.each { |status| puts " #{status[:shop]}" }
34
+
35
+ puts "Failed (#{failed.size}):"
36
+ failed.each { |status| puts " #{status[:shop]}: #{status[:error]}" }
37
+
38
+ exit 1 unless failed.empty?
39
+ end
40
+ end
41
+ end
42
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shopify_api-graphql-request
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Skye Shaw
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-19 00:00:00.000000000 Z
11
+ date: 2026-11-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: shopify_api-graphql-tiny
@@ -64,6 +64,20 @@ dependencies:
64
64
  - - ">="
65
65
  - !ruby/object:Gem::Version
66
66
  version: '0'
67
+ - !ruby/object:Gem::Dependency
68
+ name: csv
69
+ requirement: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '3.0'
74
+ type: :runtime
75
+ prerelease: false
76
+ version_requirements: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '3.0'
67
81
  - !ruby/object:Gem::Dependency
68
82
  name: bundler
69
83
  requirement: !ruby/object:Gem::Requirement
@@ -106,6 +120,34 @@ dependencies:
106
120
  - - "~>"
107
121
  - !ruby/object:Gem::Version
108
122
  version: '3.0'
123
+ - !ruby/object:Gem::Dependency
124
+ name: webmock
125
+ requirement: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '3.0'
130
+ type: :development
131
+ prerelease: false
132
+ version_requirements: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - "~>"
135
+ - !ruby/object:Gem::Version
136
+ version: '3.0'
137
+ - !ruby/object:Gem::Dependency
138
+ name: vcr
139
+ requirement: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - "~>"
142
+ - !ruby/object:Gem::Version
143
+ version: '6.0'
144
+ type: :development
145
+ prerelease: false
146
+ version_requirements: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - "~>"
149
+ - !ruby/object:Gem::Version
150
+ version: '6.0'
109
151
  description:
110
152
  email:
111
153
  - skye.shaw@gmail.com
@@ -120,7 +162,12 @@ files:
120
162
  - README.md
121
163
  - Rakefile
122
164
  - lib/shopify_api/graphql/request.rb
165
+ - lib/shopify_api/graphql/request/oauth/access_token.rb
166
+ - lib/shopify_api/graphql/request/oauth/access_token_migrator.rb
167
+ - lib/shopify_api/graphql/request/railtie.rb
168
+ - lib/shopify_api/graphql/request/tasks.rb
123
169
  - lib/shopify_api/graphql/request/version.rb
170
+ - lib/tasks/shopify.rake
124
171
  homepage:
125
172
  licenses:
126
173
  - MIT
@@ -148,6 +195,6 @@ rubygems_version: 3.5.16
148
195
  signing_key:
149
196
  specification_version: 4
150
197
  summary: Small class to simplify the writing and handling of GraphQL queries and mutations
151
- for the Shopify Admin API. Comes with built-in retry, pagination, error handling,
152
- and more!
198
+ for the Shopify Admin API. Comes with built-in retry, token refresh, pagination,
199
+ error handling, and more!
153
200
  test_files: []