shopify_api-graphql-request 0.0.1 → 0.1.0
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 +166 -19
- data/lib/shopify_api/graphql/request/oauth/access_token.rb +189 -0
- data/lib/shopify_api/graphql/request/oauth/access_token_migrator.rb +124 -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 +165 -15
- data/lib/tasks/shopify.rake +32 -0
- metadata +66 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bee339fb15f11c233c7dc08e8e7333282a4e3372ce44278437bbff81898fd9a7
|
|
4
|
+
data.tar.gz: a83aaa1a7d828dc311ada7c1c4d65480d7bcef63cb3fa40f0e229978e8be25cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3290890d3abc1b59a82b053d038cae873b0038b478253f7e12ea8a7ee20d2c216c3ae5e86e9d80f8970cdc4b6245e516c723ba34ac60b16a12e45a90b37a1d83
|
|
7
|
+
data.tar.gz: f3e684ef553213512ff80f9252a171b32055746c1d9b4a1934cf4e470e30caf7ee594248ee5fbf42887435d25573be57d70be9d022ba1bfa4784e32d770bf34c
|
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
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
v0.1.0 2026-11-10
|
|
2
|
+
--------------------
|
|
3
|
+
Add OAuth::AccessToken to support Shopify expiring tokens
|
|
4
|
+
Add :refresh option to Request to automatically refresh expiring tokens
|
|
5
|
+
Add shopify_api:graphql:request:migrate_access_tokens Rake task for non-expiring token migration
|
|
6
|
+
|
|
7
|
+
v0.0.2 2026-04-19
|
|
8
|
+
--------------------
|
|
9
|
+
Require TinyGID >= 0.1.2. This version allows one to call #parse and #to_s methods on #gid.
|
|
10
|
+
Make #transform_keys private to communicate to implementors that it should not be called
|
data/README.md
CHANGED
|
@@ -1,15 +1,42 @@
|
|
|
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
|
|
|
8
|
-
|
|
10
|
+
Queries:
|
|
9
11
|
|
|
10
12
|
```rb
|
|
11
13
|
require "shopify_api/graphql/request"
|
|
12
14
|
|
|
15
|
+
request = ShopifyAPI::GraphQL::Request.new("a-shop", token)
|
|
16
|
+
|
|
17
|
+
begin
|
|
18
|
+
product = request.execute(query, :id => "gid://shopify/Product/123").dig(:data, :product)
|
|
19
|
+
p product[:title]
|
|
20
|
+
p product[:description_html]
|
|
21
|
+
rescue ShopifyAPI::GraphQL::Request::NotFoundError => e
|
|
22
|
+
p e
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
And mutations:
|
|
27
|
+
|
|
28
|
+
```rb
|
|
29
|
+
begin
|
|
30
|
+
product = request.execute(mutation, :id => "gid://shopify/Product/123", :title => "Foo Hoo!").dig(:data, :product)
|
|
31
|
+
rescue ShopifyAPI::GraphQL::Request::UserError => e
|
|
32
|
+
p e
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
If your application contains a lot of query and/or mutations it's recommended to organize them by
|
|
37
|
+
subclassing `ShopifyAPI::GraphQL::Request`:
|
|
38
|
+
|
|
39
|
+
```rb
|
|
13
40
|
class ShopifyProduct < ShopifyAPI::GraphQL::Request
|
|
14
41
|
# Define your queries/mutations
|
|
15
42
|
FIND =<<-GQL
|
|
@@ -149,38 +176,159 @@ class ShopifyProduct < ShopifyAPI::GraphQL::Request
|
|
|
149
176
|
end
|
|
150
177
|
```
|
|
151
178
|
|
|
152
|
-
###
|
|
179
|
+
### Auto-Refreshing Expiring Access Tokens
|
|
153
180
|
|
|
154
|
-
|
|
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:
|
|
155
184
|
|
|
156
185
|
```rb
|
|
157
|
-
|
|
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
|
+
```
|
|
158
209
|
|
|
159
|
-
|
|
210
|
+
#### The `:synchronize` Callback
|
|
160
211
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
167
237
|
end
|
|
168
238
|
```
|
|
169
239
|
|
|
170
|
-
|
|
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`:
|
|
171
243
|
|
|
172
244
|
```rb
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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. The file is `load`ed and must define `update_token(shop, tokens)`, which is called with the
|
|
294
|
+
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"], ...)
|
|
177
300
|
end
|
|
178
301
|
```
|
|
179
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
|
+
#### Recovering a Lost Response
|
|
308
|
+
|
|
309
|
+
If a migration request fails or the response is lost, you can safely repeat the `#migrate` call with the original non-expiring token within
|
|
310
|
+
7 days of the initial exchange. Shopify will return the same token pair.
|
|
311
|
+
|
|
180
312
|
### More Info
|
|
181
313
|
|
|
182
314
|
For more information checkout [the API docs](https://rdoc.info/gems/shopify_api-graphql-request)
|
|
183
315
|
|
|
316
|
+
## Why Use This Instead of Shopify's API Client?
|
|
317
|
+
|
|
318
|
+
- Easy-to-use
|
|
319
|
+
- Built-in retry
|
|
320
|
+
- Built-in pagination
|
|
321
|
+
- Improved exception handling
|
|
322
|
+
- You can use `:snake_case` hash keys
|
|
323
|
+
- Lightweight
|
|
324
|
+
|
|
325
|
+
Overall, Shopify's API client is bloated trash that will give you development headaches and long-term maintenance nightmares.
|
|
326
|
+
|
|
327
|
+
We used to use it, staring way back in 2015, but eventually had to pivot away from their Ruby libraries due to developer
|
|
328
|
+
frustration and high maintenance cost (and don't get us started on the ShopifyApp gem!@#).
|
|
329
|
+
|
|
330
|
+
For more information see: https://github.com/Shopify/shopify-api-ruby/issues/1181
|
|
331
|
+
|
|
184
332
|
## Testing
|
|
185
333
|
|
|
186
334
|
`cp env.template .env` and fill-in `.env` with the missing values. This requires a Shopify store.
|
|
@@ -188,7 +336,6 @@ For more information checkout [the API docs](https://rdoc.info/gems/shopify_api-
|
|
|
188
336
|
## See Also
|
|
189
337
|
|
|
190
338
|
- [Shopify Dev Tools](https://github.com/ScreenStaring/shopify-dev-tools/) - Command-line program to assist with the development and/or maintenance of Shopify apps and stores
|
|
191
|
-
- [Shopify ID Export](https://github.com/ScreenStaring/shopify_id_export/) - Dump Shopify product and variant IDs —along with other identifiers— to a CSV or JSON file
|
|
192
339
|
- [`ShopifyAPI::GraphQL::Tiny`](https://github.com/ScreenStaring/shopify_api-graphql-tiny/) - Lightweight, no-nonsense, Shopify GraphQL Admin API client with built-in pagination and retry
|
|
193
340
|
- [`TinyGID`](https://github.com/sshaw/tiny_gid/) - Build Global ID (`gid://`) URI strings from scalar values
|
|
194
341
|
|
|
@@ -198,4 +345,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
|
|
|
198
345
|
|
|
199
346
|
---
|
|
200
347
|
|
|
201
|
-
Made by [ScreenStaring](
|
|
348
|
+
Made by [ScreenStaring](https://screenstaring.com)
|
|
@@ -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,124 @@
|
|
|
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 +{ :shop => String, :result => Hash }+ per migrated shop
|
|
62
|
+
#
|
|
63
|
+
# === Errors
|
|
64
|
+
#
|
|
65
|
+
# ArgumentError, AccessToken::InvalidTokenError, AccessToken::Error
|
|
66
|
+
#
|
|
67
|
+
def run
|
|
68
|
+
pairs = @shop ? [single_shop_and_token] : rows
|
|
69
|
+
load_handler if @handler
|
|
70
|
+
|
|
71
|
+
pairs.map { |shop, token| migrate(shop, token) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def nil_if_blank(value)
|
|
77
|
+
value unless value.to_s.strip.empty?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def load_handler
|
|
81
|
+
raise ArgumentError, "Handler not found: #{@handler}" unless File.file?(@handler)
|
|
82
|
+
|
|
83
|
+
load File.expand_path(@handler)
|
|
84
|
+
return if respond_to?(HANDLER_METHOD, true)
|
|
85
|
+
|
|
86
|
+
raise ArgumentError, "Handler must define #{HANDLER_METHOD}: #{@handler}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def single_shop_and_token
|
|
90
|
+
raise ArgumentError, "token required" unless @token
|
|
91
|
+
|
|
92
|
+
[@shop, @token]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def rows
|
|
96
|
+
raise ArgumentError, "File not found: #{@file}" unless File.file?(@file)
|
|
97
|
+
|
|
98
|
+
table = CSV.read(@file, :headers => true, :skip_blanks => true, :converters => ->(value) { value&.strip }, :header_converters => ->(h) { h.to_s.strip.downcase })
|
|
99
|
+
unless table.headers.include?("shop") && table.headers.include?("token")
|
|
100
|
+
raise ArgumentError, "CSV must contain 'shop' and 'token' columns"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
table.map { |row| [row["shop"], row["token"]] }.reject { |shop, token| shop.nil? && token.nil? }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def migrate(shop, token)
|
|
107
|
+
client = AccessToken.new(@client_id, @client_secret, shop)
|
|
108
|
+
result = client.migrate(token)
|
|
109
|
+
|
|
110
|
+
@out.puts result.pretty_inspect if @shop
|
|
111
|
+
|
|
112
|
+
call_handler(result, shop) if @handler
|
|
113
|
+
|
|
114
|
+
{ :shop => shop, :result => result }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def call_handler(result, shop)
|
|
118
|
+
send(HANDLER_METHOD, shop, result)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
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
|
##
|
|
@@ -89,9 +131,9 @@ module ShopifyAPI
|
|
|
89
131
|
#
|
|
90
132
|
# These override the instance's defaults for a single query or mutation.
|
|
91
133
|
#
|
|
92
|
-
# [:raise_if_not_found (Boolean)]
|
|
93
|
-
# [:raise_if_user_errors (Boolean)]
|
|
94
|
-
# [:snake_case (Boolean)]
|
|
134
|
+
# [:raise_if_not_found (Boolean)] - raise a `ShopifyAPI::GraphQL::Request::NotFoundError` if query target is not found; defaults to +true+
|
|
135
|
+
# [:raise_if_user_errors (Boolean)] - raise a `ShopifyAPI::GraphQL::Request::UserError` if the response contains them; defaults to +true+
|
|
136
|
+
# [:snake_case (Boolean)] - Accept `:snake_case`-style hash keys and returned response hash keys to be snake_case; defaults to +true+
|
|
95
137
|
#
|
|
96
138
|
# === Returns
|
|
97
139
|
#
|
|
@@ -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]
|
|
@@ -137,26 +188,34 @@ module ShopifyAPI
|
|
|
137
188
|
#
|
|
138
189
|
# === Errors
|
|
139
190
|
#
|
|
140
|
-
#
|
|
191
|
+
# See #execute
|
|
141
192
|
#
|
|
142
193
|
def paginate(query, variables = nil, options = nil)
|
|
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
|
|
@@ -189,6 +248,94 @@ module ShopifyAPI
|
|
|
189
248
|
transform_keys(object) { |key| Strings::Case.snakecase(key).to_sym }
|
|
190
249
|
end
|
|
191
250
|
|
|
251
|
+
private
|
|
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
|
+
|
|
192
339
|
def transform_keys(object, &transformer)
|
|
193
340
|
case object
|
|
194
341
|
when Hash
|
|
@@ -204,3 +351,6 @@ module ShopifyAPI
|
|
|
204
351
|
end
|
|
205
352
|
end
|
|
206
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,32 @@
|
|
|
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
|
+
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
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
metadata
CHANGED
|
@@ -1,13 +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
|
|
4
|
+
version: 0.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Skye Shaw
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: exe
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
11
|
+
date: 2026-11-10 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
14
|
name: shopify_api-graphql-tiny
|
|
@@ -35,14 +36,20 @@ dependencies:
|
|
|
35
36
|
requirements:
|
|
36
37
|
- - ">="
|
|
37
38
|
- !ruby/object:Gem::Version
|
|
38
|
-
version:
|
|
39
|
+
version: 0.1.2
|
|
40
|
+
- - "<"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '2'
|
|
39
43
|
type: :runtime
|
|
40
44
|
prerelease: false
|
|
41
45
|
version_requirements: !ruby/object:Gem::Requirement
|
|
42
46
|
requirements:
|
|
43
47
|
- - ">="
|
|
44
48
|
- !ruby/object:Gem::Version
|
|
45
|
-
version:
|
|
49
|
+
version: 0.1.2
|
|
50
|
+
- - "<"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '2'
|
|
46
53
|
- !ruby/object:Gem::Dependency
|
|
47
54
|
name: strings-case
|
|
48
55
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -57,6 +64,20 @@ dependencies:
|
|
|
57
64
|
- - ">="
|
|
58
65
|
- !ruby/object:Gem::Version
|
|
59
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'
|
|
60
81
|
- !ruby/object:Gem::Dependency
|
|
61
82
|
name: bundler
|
|
62
83
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -99,6 +120,35 @@ dependencies:
|
|
|
99
120
|
- - "~>"
|
|
100
121
|
- !ruby/object:Gem::Version
|
|
101
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'
|
|
151
|
+
description:
|
|
102
152
|
email:
|
|
103
153
|
- skye.shaw@gmail.com
|
|
104
154
|
executables: []
|
|
@@ -107,11 +157,18 @@ extra_rdoc_files: []
|
|
|
107
157
|
files:
|
|
108
158
|
- ".env.template"
|
|
109
159
|
- ".rspec"
|
|
160
|
+
- Changes
|
|
110
161
|
- LICENSE.txt
|
|
111
162
|
- README.md
|
|
112
163
|
- Rakefile
|
|
113
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
|
|
114
169
|
- lib/shopify_api/graphql/request/version.rb
|
|
170
|
+
- lib/tasks/shopify.rake
|
|
171
|
+
homepage:
|
|
115
172
|
licenses:
|
|
116
173
|
- MIT
|
|
117
174
|
metadata:
|
|
@@ -119,6 +176,7 @@ metadata:
|
|
|
119
176
|
changelog_uri: https://github.com/ScreenStaring/shopify_api-graphql-request/blob/master/Changes
|
|
120
177
|
documentation_uri: https://rubydoc.info/gems/shopify_api-graphql-request
|
|
121
178
|
source_code_uri: https://github.com/ScreenStaring/shopify_api-graphql-request
|
|
179
|
+
post_install_message:
|
|
122
180
|
rdoc_options: []
|
|
123
181
|
require_paths:
|
|
124
182
|
- lib
|
|
@@ -133,9 +191,10 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
133
191
|
- !ruby/object:Gem::Version
|
|
134
192
|
version: '0'
|
|
135
193
|
requirements: []
|
|
136
|
-
rubygems_version: 3.
|
|
194
|
+
rubygems_version: 3.5.16
|
|
195
|
+
signing_key:
|
|
137
196
|
specification_version: 4
|
|
138
197
|
summary: Small class to simplify the writing and handling of GraphQL queries and mutations
|
|
139
|
-
for the Shopify Admin API. Comes with built-in retry,
|
|
140
|
-
and more!
|
|
198
|
+
for the Shopify Admin API. Comes with built-in retry, token refresh, pagination,
|
|
199
|
+
error handling, and more!
|
|
141
200
|
test_files: []
|