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 +4 -4
- data/.env.template +7 -0
- data/Changes +10 -0
- data/README.md +138 -1
- data/lib/shopify_api/graphql/request/oauth/access_token.rb +189 -0
- data/lib/shopify_api/graphql/request/oauth/access_token_migrator.rb +130 -0
- data/lib/shopify_api/graphql/request/railtie.rb +14 -0
- data/lib/shopify_api/graphql/request/tasks.rb +3 -0
- data/lib/shopify_api/graphql/request/version.rb +1 -1
- data/lib/shopify_api/graphql/request.rb +159 -11
- data/lib/tasks/shopify.rake +42 -0
- metadata +51 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8693e4abd154bf4592d8582f4f00c542a22440a47554529cabca27a2acca9102
|
|
4
|
+
data.tar.gz: 2baa318c0578781d9419bb7916d26eeb59a09783d4ccaddfe577d485a9a6a3da
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
[](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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
206
|
+
page = snake_case_keys(page) if options[:snake_case]
|
|
207
|
+
page
|
|
208
|
+
end
|
|
156
209
|
|
|
157
|
-
|
|
210
|
+
return pages unless block_given?
|
|
158
211
|
|
|
159
|
-
|
|
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.
|
|
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-
|
|
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,
|
|
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: []
|