shopify-client 0.0.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 +7 -0
- data/README.md +342 -0
- data/lib/shopify-client.rb +49 -0
- data/lib/shopify-client/authorise.rb +38 -0
- data/lib/shopify-client/bulk_request.rb +219 -0
- data/lib/shopify-client/cache/redis_store.rb +28 -0
- data/lib/shopify-client/cache/store.rb +67 -0
- data/lib/shopify-client/cache/thread_local_store.rb +47 -0
- data/lib/shopify-client/cached_request.rb +69 -0
- data/lib/shopify-client/client.rb +126 -0
- data/lib/shopify-client/client/logging.rb +38 -0
- data/lib/shopify-client/client/normalise_path.rb +14 -0
- data/lib/shopify-client/cookieless/check_header.rb +28 -0
- data/lib/shopify-client/cookieless/decode_session_token.rb +43 -0
- data/lib/shopify-client/cookieless/middleware.rb +39 -0
- data/lib/shopify-client/create_all_webhooks.rb +23 -0
- data/lib/shopify-client/create_webhook.rb +21 -0
- data/lib/shopify-client/delete_all_webhooks.rb +22 -0
- data/lib/shopify-client/delete_webhook.rb +13 -0
- data/lib/shopify-client/error.rb +9 -0
- data/lib/shopify-client/parse_link_header.rb +33 -0
- data/lib/shopify-client/request.rb +40 -0
- data/lib/shopify-client/resource/base.rb +46 -0
- data/lib/shopify-client/resource/create.rb +31 -0
- data/lib/shopify-client/resource/delete.rb +29 -0
- data/lib/shopify-client/resource/read.rb +80 -0
- data/lib/shopify-client/resource/update.rb +30 -0
- data/lib/shopify-client/response.rb +201 -0
- data/lib/shopify-client/response_errors.rb +59 -0
- data/lib/shopify-client/response_user_errors.rb +42 -0
- data/lib/shopify-client/struct.rb +10 -0
- data/lib/shopify-client/throttling/redis_strategy.rb +62 -0
- data/lib/shopify-client/throttling/strategy.rb +50 -0
- data/lib/shopify-client/throttling/thread_local_strategy.rb +29 -0
- data/lib/shopify-client/verify_request.rb +51 -0
- data/lib/shopify-client/verify_webhook.rb +24 -0
- data/lib/shopify-client/version.rb +5 -0
- data/lib/shopify-client/webhook.rb +32 -0
- data/lib/shopify-client/webhook_list.rb +50 -0
- metadata +206 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 996fd4c7f7e89ddeff22da53168828c5d8bd1a87ad3d965a512c10aee7c99850
|
4
|
+
data.tar.gz: 0c2829baff3a2518d6a4ab44544a975a7f72dcb8614d75b703e0da46f3238b25
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 93aa4de201357efbf4a51f5924bf1f547d85ee822f5fe35f34b2a1e7cc23645849026bb309de43f48644a94ff6772f63b967e98dad5ca1670d305c1877f411c2
|
7
|
+
data.tar.gz: 2930f0d23bb145629ab392015142b213d4470d81c69675890ad9594983f8acf6fcac91ce1fabb68acb7207d779f9282faa6d261b9ee3249d021dad58dda101e9
|
data/README.md
ADDED
@@ -0,0 +1,342 @@
|
|
1
|
+
shopify-client
|
2
|
+
==============
|
3
|
+
|
4
|
+
1. [Installation](#installation)
|
5
|
+
2. [Setup](#setup)
|
6
|
+
* [Configuration](#configuration)
|
7
|
+
3. [Calling the API](#calling-the-api)
|
8
|
+
* [Make API requests](#make-api-requests)
|
9
|
+
* [Make bulk API requests](#make-bulk-api-requests)
|
10
|
+
* [Make cached API requests](#make-cached-api-requests)
|
11
|
+
* [Pagination](#pagination)
|
12
|
+
4. [OAuth](#oauth)
|
13
|
+
5. [Cookieless authentication](#cookieless-authentication)
|
14
|
+
* [Rack middleware](#rack-middleware)
|
15
|
+
* [Manual check](#manual-check)
|
16
|
+
6. [Webhooks](#webhooks)
|
17
|
+
* [Configure webhooks](#configure-webhooks)
|
18
|
+
* [Create and delete webhooks](#create-and-delete-webhooks)
|
19
|
+
7. [Verification](#verification)
|
20
|
+
* [Verify callbacks](#verify-callbacks)
|
21
|
+
* [Verify webhooks](#verify-webhooks)
|
22
|
+
8. [Mixins](#mixins)
|
23
|
+
* [Read a resource](#read-a-resource)
|
24
|
+
* [Create a resource](#create-a-resource)
|
25
|
+
* [Update a resource](#update-a-resource)
|
26
|
+
* [Delete a resource](#delete-a-resource)
|
27
|
+
|
28
|
+
|
29
|
+
Installation
|
30
|
+
------------
|
31
|
+
|
32
|
+
Add the gem to your 'Gemfile':
|
33
|
+
|
34
|
+
gem 'shopify-client'
|
35
|
+
|
36
|
+
|
37
|
+
Setup
|
38
|
+
-----
|
39
|
+
|
40
|
+
### Configuration
|
41
|
+
|
42
|
+
ShopifyClient.configure do |config|
|
43
|
+
config.api_key = '...'
|
44
|
+
config.api_version = '...' # e.g. '2021-04'
|
45
|
+
config.cache_ttl = 3600
|
46
|
+
config.redirect_uri = '...' # for OAuth
|
47
|
+
config.logger = Logger.new(STDOUT) # defaults to a null logger
|
48
|
+
config.scope = '...'
|
49
|
+
config.shared_secret = '...'
|
50
|
+
config.webhook_uri = '...'
|
51
|
+
end
|
52
|
+
|
53
|
+
All settings are optional and in some private apps, you may not require any
|
54
|
+
configuration at all.
|
55
|
+
|
56
|
+
|
57
|
+
Calling the API
|
58
|
+
---------------
|
59
|
+
|
60
|
+
### Make API requests
|
61
|
+
|
62
|
+
client = ShopifyClient::Client.new(myshopify_domain, access_token)
|
63
|
+
|
64
|
+
client.get('orders', since_id: since_id).data['orders']
|
65
|
+
client.post('orders', order: new_order)
|
66
|
+
client.graphql(%(
|
67
|
+
{
|
68
|
+
orders(first: 10) {
|
69
|
+
edges {
|
70
|
+
node {
|
71
|
+
id
|
72
|
+
tags
|
73
|
+
}
|
74
|
+
}
|
75
|
+
}
|
76
|
+
}
|
77
|
+
)).data['data']['orders']
|
78
|
+
|
79
|
+
Request logging is disabled by default. To enable it:
|
80
|
+
|
81
|
+
ShopifyClient.config.logger = Logger.new(STDOUT)
|
82
|
+
|
83
|
+
Request throttling is enabled by default. If you're using Redis, throttling will
|
84
|
+
automatically make use of it; otherwise, throttling will only be maintained
|
85
|
+
across a single thread.
|
86
|
+
|
87
|
+
|
88
|
+
### Make bulk API requests
|
89
|
+
|
90
|
+
The gem wraps Shopify's bulk query API by writing the result to a temporary file
|
91
|
+
and yielding an enumerator which itself streams each line of the result to limit
|
92
|
+
memory usage.
|
93
|
+
|
94
|
+
client.grahql_bulk(%(
|
95
|
+
{
|
96
|
+
products {
|
97
|
+
edges {
|
98
|
+
node {
|
99
|
+
id
|
100
|
+
handle
|
101
|
+
}
|
102
|
+
}
|
103
|
+
}
|
104
|
+
}
|
105
|
+
)) do |lines|
|
106
|
+
db.transaction do
|
107
|
+
lines.each do |product|
|
108
|
+
db[:products].insert(
|
109
|
+
id: product['id'],
|
110
|
+
handle: product['handle'],
|
111
|
+
)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
Bulk requests are limited to one per shop at any one time. Creating a new bulk
|
117
|
+
request via the gem will cancel any request in progress for the shop.
|
118
|
+
|
119
|
+
|
120
|
+
### Make cached API requests
|
121
|
+
|
122
|
+
Make a cached GET request:
|
123
|
+
|
124
|
+
client.get_cached('orders', params: {since_id: since_id})
|
125
|
+
|
126
|
+
Note that unlike `#get`, `#get_cached` returns the `Response#data` hash rather
|
127
|
+
than a `Response` object.
|
128
|
+
|
129
|
+
Making the same call with the same shop/client, will result in the data being
|
130
|
+
returned straight from the cache on subsequent calls, until the configured TTL
|
131
|
+
expires (the default TTL is 1 hour). If you're using Redis, it will be used as
|
132
|
+
the cache store; otherwise, the cache will be stored in a thread local variable.
|
133
|
+
|
134
|
+
You can also manually build and clear a cached request. For example, you might
|
135
|
+
need to clear the cache without waiting for the TTL if you receive an update
|
136
|
+
webhook indicating that the cached data is obsolete:
|
137
|
+
|
138
|
+
get_shop = ShopifyClient::CachedRequest.new('shop')
|
139
|
+
|
140
|
+
# Request shop data (from API).
|
141
|
+
get_shop.(client)
|
142
|
+
# Request shop data (from cache).
|
143
|
+
get_shop.(client)
|
144
|
+
|
145
|
+
Clear the cache data to force fetch from API on next access:
|
146
|
+
|
147
|
+
get_shop.clear(myshopify_domain)
|
148
|
+
|
149
|
+
Set the cache data (e.g. from shop/update webhook body):
|
150
|
+
|
151
|
+
get_shop.set(myshopify_domain, new_shop)
|
152
|
+
|
153
|
+
|
154
|
+
### Pagination
|
155
|
+
|
156
|
+
When you make a GET request, you can request the next or the previous page
|
157
|
+
directly from the response object.
|
158
|
+
|
159
|
+
page_1 = client.get('orders')
|
160
|
+
page_2 = page_1.next_page
|
161
|
+
page_1 = page_2.previous_page
|
162
|
+
|
163
|
+
When no page is available, `nil` will be returned.
|
164
|
+
|
165
|
+
|
166
|
+
OAuth
|
167
|
+
-----
|
168
|
+
|
169
|
+
Redirect unauthorised users through the Shopify OAuth flow:
|
170
|
+
|
171
|
+
authorise = ShopifyClient::Authorise.new
|
172
|
+
|
173
|
+
redirect_to authorise.authorisation_code_url(myshopify_domain)
|
174
|
+
|
175
|
+
Once the user returns to the app, exchange the authorisation code for an access
|
176
|
+
token:
|
177
|
+
|
178
|
+
access_token = authorise.(client, authorisation_code)
|
179
|
+
|
180
|
+
|
181
|
+
Cookieless authentication
|
182
|
+
-------------------------
|
183
|
+
|
184
|
+
Embedded apps using App Bridge are required to use the cookieless authentication
|
185
|
+
system which uses JWT session tokens rather than cookies to authenticate users
|
186
|
+
signed into the Shopify admin.
|
187
|
+
|
188
|
+
|
189
|
+
### Rack middleware
|
190
|
+
|
191
|
+
In config.ru, or wherever you set up your middleware stack:
|
192
|
+
|
193
|
+
use ShopifyClient::Cookieless::Middleware
|
194
|
+
|
195
|
+
You can also control when session tokens are checked with a predicate (such as
|
196
|
+
only for certain paths):
|
197
|
+
|
198
|
+
use ShopifyClient::Cookieless::Middleware, is_authenticated: ->(env) do
|
199
|
+
# ...
|
200
|
+
end
|
201
|
+
|
202
|
+
|
203
|
+
### Manual check
|
204
|
+
|
205
|
+
You can also check the Authorization header manually, if you require more
|
206
|
+
control than the middleware provides:
|
207
|
+
|
208
|
+
begin
|
209
|
+
ShopifyClient::Cookieless::CheckHeader.new.(env)
|
210
|
+
rescue ShopifyClient::Error => e
|
211
|
+
# ...
|
212
|
+
end
|
213
|
+
|
214
|
+
|
215
|
+
Webhooks
|
216
|
+
--------
|
217
|
+
|
218
|
+
### Configure webhooks
|
219
|
+
|
220
|
+
Configure each webhook the app will create (if any), and register handlers:
|
221
|
+
|
222
|
+
ShopifyClient.webhooks.register('orders/create', OrdersCreateWebhook.new, fields: %w[id tags])
|
223
|
+
|
224
|
+
You can register as many handlers as you need for a topic, and the gem will
|
225
|
+
merge required fields across all handlers when creating the webhooks.
|
226
|
+
|
227
|
+
To call/delegate a webhook to its handler for processing, you will likely want
|
228
|
+
to create a worker around something like this:
|
229
|
+
|
230
|
+
webhook = ShopifyClient::Webhook.new(myshopify_domain, topic, data)
|
231
|
+
|
232
|
+
ShopifyClient.webhooks.delegate(webhook)
|
233
|
+
|
234
|
+
|
235
|
+
### Create and delete webhooks
|
236
|
+
|
237
|
+
Create/delete all configured webhooks (see above):
|
238
|
+
|
239
|
+
ShopifyClient::CreateAllWebhooks.new.(client)
|
240
|
+
ShopifyClient::DeleteAllWebhooks.new.(client)
|
241
|
+
|
242
|
+
Create/delete webhooks manually:
|
243
|
+
|
244
|
+
webhook = {topic: 'orders/create', fields: %w[id tags]}
|
245
|
+
|
246
|
+
ShopifyClient::CreateWebhook.new.(client, webhook)
|
247
|
+
ShopifyClient::DeleteWebhook.new.(client, webhook_id)
|
248
|
+
|
249
|
+
|
250
|
+
Verification
|
251
|
+
------------
|
252
|
+
|
253
|
+
### Verify requests
|
254
|
+
|
255
|
+
Verify callback requests with the request params:
|
256
|
+
|
257
|
+
begin
|
258
|
+
ShopifyClient::VerifyRequest.new.(params)
|
259
|
+
rescue ShopifyClient::Error => e
|
260
|
+
# ...
|
261
|
+
end
|
262
|
+
|
263
|
+
|
264
|
+
### Verify webhooks
|
265
|
+
|
266
|
+
Verify webhook requests with the request data and the HMAC header:
|
267
|
+
|
268
|
+
begin
|
269
|
+
ShopifyClient::VerifyWebhook.new.(data, hmac)
|
270
|
+
rescue ShopifyClient::Error => e
|
271
|
+
# ...
|
272
|
+
end
|
273
|
+
|
274
|
+
|
275
|
+
Mixins
|
276
|
+
------
|
277
|
+
|
278
|
+
A set of mixins is provided for easily creating repository classes for API
|
279
|
+
resources. Each mixin represents an operation or a set of operations, e.g.
|
280
|
+
reading and writing data to/from the API.
|
281
|
+
|
282
|
+
|
283
|
+
### Read a resource
|
284
|
+
|
285
|
+
class OrderRepository
|
286
|
+
include ShopifyClient::Resource::Read
|
287
|
+
|
288
|
+
resource :orders
|
289
|
+
|
290
|
+
default_params fields: 'id,tags', limit: 250
|
291
|
+
end
|
292
|
+
|
293
|
+
order_repo = OrderRepository.new
|
294
|
+
|
295
|
+
Find a single result:
|
296
|
+
|
297
|
+
order_repo.find_by_id(client, id)
|
298
|
+
|
299
|
+
Iterate over results (automatic pagination):
|
300
|
+
|
301
|
+
order_repo.all.each do |order|
|
302
|
+
# ...
|
303
|
+
end
|
304
|
+
|
305
|
+
|
306
|
+
### Create a resource
|
307
|
+
|
308
|
+
class OrderRepository
|
309
|
+
include ShopifyClient::Resource::Create
|
310
|
+
|
311
|
+
resource :orders
|
312
|
+
end
|
313
|
+
|
314
|
+
order_repo = OrderRepository.new
|
315
|
+
|
316
|
+
order_repo.create(client, new_order)
|
317
|
+
|
318
|
+
|
319
|
+
### Update a resource
|
320
|
+
|
321
|
+
class OrderRepository
|
322
|
+
include ShopifyClient::Resource::Update
|
323
|
+
|
324
|
+
resource :orders
|
325
|
+
end
|
326
|
+
|
327
|
+
order_repo = OrderRepository.new
|
328
|
+
|
329
|
+
order_repo.update(client, id, order)
|
330
|
+
|
331
|
+
|
332
|
+
### Delete a resource
|
333
|
+
|
334
|
+
class OrderRepository
|
335
|
+
include ShopifyClient::Resource::Delete
|
336
|
+
|
337
|
+
resource :orders
|
338
|
+
end
|
339
|
+
|
340
|
+
order_repo = OrderRepository.new
|
341
|
+
|
342
|
+
order_repo.delete(client, id)
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry-configurable'
|
4
|
+
require 'logger'
|
5
|
+
require 'zeitwerk'
|
6
|
+
|
7
|
+
loader = Zeitwerk::Loader.new
|
8
|
+
loader.push_dir(__dir__)
|
9
|
+
loader.tag = File.basename(__FILE__, '.rb')
|
10
|
+
loader.inflector.inflect(
|
11
|
+
'shopify-client' => 'ShopifyClient',
|
12
|
+
'version' => 'VERSION',
|
13
|
+
)
|
14
|
+
loader.setup
|
15
|
+
|
16
|
+
module ShopifyClient
|
17
|
+
extend Dry::Configurable
|
18
|
+
|
19
|
+
setting :api_key
|
20
|
+
setting :api_version, '2021-04'
|
21
|
+
setting :cache_ttl, 3600
|
22
|
+
setting :logger, Logger.new(File::NULL).freeze
|
23
|
+
setting :oauth_redirect_uri
|
24
|
+
setting :oauth_scope
|
25
|
+
setting :shared_secret
|
26
|
+
setting :webhook_uri
|
27
|
+
|
28
|
+
class << self
|
29
|
+
# @param version [String]
|
30
|
+
#
|
31
|
+
# @raise [RuntimeError]
|
32
|
+
def assert_api_version!(version)
|
33
|
+
raise "requires API version >= #{version}" if config.api_version < version
|
34
|
+
end
|
35
|
+
|
36
|
+
# @return [WebhookList]
|
37
|
+
#
|
38
|
+
# @example Register webhook handlers
|
39
|
+
# ShopifyClient.webhooks.register('orders/create', OrdersCreateWebhook.new, fields: %w[id tags])
|
40
|
+
#
|
41
|
+
# @example Call handlers for a topic
|
42
|
+
# webhook = Webhook.new(myshopify_domain, topic, data)
|
43
|
+
#
|
44
|
+
# ShopifyClient.webhooks.delegate(webhook)
|
45
|
+
def webhooks
|
46
|
+
@webhooks ||= WebhookList.new
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ShopifyClient
|
4
|
+
class Authorise
|
5
|
+
Error = Class.new(Error)
|
6
|
+
|
7
|
+
# @param myshopify_domain [String]
|
8
|
+
def authorisation_code_url(myshopify_domain)
|
9
|
+
format('https://%s/admin/oauth/authorize?client_id=%s&scope=%s&redirect_uri=%s',
|
10
|
+
myshopify_domain,
|
11
|
+
ShopifyClient.config.api_key,
|
12
|
+
ShopifyClient.config.scope,
|
13
|
+
ShopifyClient.config.redirect_uri,
|
14
|
+
]
|
15
|
+
end
|
16
|
+
|
17
|
+
# Exchange an authorisation code for a new Shopify access token.
|
18
|
+
#
|
19
|
+
# @param client [Client]
|
20
|
+
# @param authorisation_code [String]
|
21
|
+
#
|
22
|
+
# @return [String] the access token
|
23
|
+
#
|
24
|
+
# @raise [Error] if the response is invalid
|
25
|
+
def call(client, authorisation_code)
|
26
|
+
data = client.post('/admin/oauth/access_token', {
|
27
|
+
client_id: ShopifyClient.config.api_key,
|
28
|
+
client_secret: ShopifyClient.config.shared_secret,
|
29
|
+
code: authorisation_code,
|
30
|
+
}).data
|
31
|
+
|
32
|
+
raise Error if data['access_token'].nil?
|
33
|
+
raise Error if data['scope'] != ShopifyClient.config.scope
|
34
|
+
|
35
|
+
data['access_token']
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,219 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'faraday'
|
4
|
+
require 'json'
|
5
|
+
require 'tempfile'
|
6
|
+
require 'timeout'
|
7
|
+
|
8
|
+
module ShopifyClient
|
9
|
+
class BulkRequest
|
10
|
+
OperationError = Class.new(Error)
|
11
|
+
|
12
|
+
CanceledOperationError = Class.new(OperationError)
|
13
|
+
ExpiredOperationError = Class.new(OperationError)
|
14
|
+
FailedOperationError = Class.new(OperationError)
|
15
|
+
ObsoleteOperationError = Class.new(OperationError)
|
16
|
+
|
17
|
+
TimeoutError = Class.new(Error)
|
18
|
+
|
19
|
+
class Operation
|
20
|
+
# @param client [Client]
|
21
|
+
# @param id [String]
|
22
|
+
def initialize(client, id)
|
23
|
+
@client = client
|
24
|
+
@id = id
|
25
|
+
end
|
26
|
+
|
27
|
+
# Wait for the operation to complete, then download the JSONL result
|
28
|
+
# data which is yielded as an {Enumerator} to the block. The data is
|
29
|
+
# streamed and parsed line by line to limit memory usage.
|
30
|
+
#
|
31
|
+
# @param delay [Integer] delay between polling requests in seconds
|
32
|
+
#
|
33
|
+
# @yield [Enumerator<Hash>] yields each parsed line of JSONL
|
34
|
+
#
|
35
|
+
# @raise CanceledOperationError
|
36
|
+
# @raise ExpiredOperationError
|
37
|
+
# @raise FailedOperationError
|
38
|
+
# @raise ObsoleteOperationError
|
39
|
+
def call(delay: 1, &block)
|
40
|
+
url = loop do
|
41
|
+
status, url = poll
|
42
|
+
|
43
|
+
case status
|
44
|
+
when 'CANCELED'
|
45
|
+
raise CanceledOperationError
|
46
|
+
when 'EXPIRED'
|
47
|
+
raise ExpiredOperationError
|
48
|
+
when 'FAILED'
|
49
|
+
raise FailedOperationError
|
50
|
+
when 'COMPLETED'
|
51
|
+
break url
|
52
|
+
else
|
53
|
+
sleep(delay)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
return if url.nil?
|
58
|
+
|
59
|
+
file = Tempfile.new(mode: 0600)
|
60
|
+
|
61
|
+
begin
|
62
|
+
Faraday.get(url) do |request|
|
63
|
+
request.options.on_data = ->(chunk, _) do
|
64
|
+
file.write(chunk)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
file.rewind
|
68
|
+
block.(Enumerator.new do |y|
|
69
|
+
file.each_line { |line| y << JSON.parse(line) }
|
70
|
+
end)
|
71
|
+
ensure
|
72
|
+
file.close
|
73
|
+
file.unlink
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Cancel the bulk operation.
|
78
|
+
#
|
79
|
+
# @raise ObsoleteOperationError
|
80
|
+
# @raise TimeoutError
|
81
|
+
def cancel
|
82
|
+
begin
|
83
|
+
@client.graphql(<<~QUERY)
|
84
|
+
mutation {
|
85
|
+
bulkOperationCancel(id: "#{@id}") {
|
86
|
+
userErrors {
|
87
|
+
field
|
88
|
+
message
|
89
|
+
}
|
90
|
+
}
|
91
|
+
}
|
92
|
+
QUERY
|
93
|
+
rescue Response::GraphQLClientError => e
|
94
|
+
return if e.response.user_errors.message?([
|
95
|
+
/cannot be canceled when it is completed/,
|
96
|
+
])
|
97
|
+
|
98
|
+
raise e
|
99
|
+
end
|
100
|
+
|
101
|
+
poll_until(['CANCELED', 'COMPLETED'])
|
102
|
+
end
|
103
|
+
|
104
|
+
# Poll until operation status is met.
|
105
|
+
#
|
106
|
+
# @param statuses [Array<String>] to terminate polling on
|
107
|
+
# @param timeout [Integer] in seconds
|
108
|
+
#
|
109
|
+
# @raise ObsoleteOperationError
|
110
|
+
# @raise TimeoutError
|
111
|
+
def poll_until(statuses, timeout: 60)
|
112
|
+
Timeout.timeout(timeout) do
|
113
|
+
loop do
|
114
|
+
status, _ = poll
|
115
|
+
|
116
|
+
break if statuses.any? { |expected_status| status == expected_status }
|
117
|
+
end
|
118
|
+
end
|
119
|
+
rescue Timeout::Error
|
120
|
+
raise TimeoutError, 'exceeded %s seconds polling for status %s' % [
|
121
|
+
timeout,
|
122
|
+
statuses.join(', '),
|
123
|
+
]
|
124
|
+
end
|
125
|
+
|
126
|
+
# @return [Array(String, String | nil)] the operation status and the
|
127
|
+
# download URL, or nil if the result data is empty
|
128
|
+
private def poll
|
129
|
+
op = @client.graphql(<<~QUERY).data['data']['currentBulkOperation']
|
130
|
+
{
|
131
|
+
currentBulkOperation {
|
132
|
+
id
|
133
|
+
status
|
134
|
+
url
|
135
|
+
}
|
136
|
+
}
|
137
|
+
QUERY
|
138
|
+
|
139
|
+
raise ObsoleteOperationError if op['id'] != @id
|
140
|
+
|
141
|
+
[
|
142
|
+
op['status'],
|
143
|
+
op['url'],
|
144
|
+
]
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
# Create and start a new bulk operation via the GraphQL API. Any currently
|
149
|
+
# running bulk operations are cancelled.
|
150
|
+
#
|
151
|
+
# @param client [Client]
|
152
|
+
# @param query [String] the GraphQL query
|
153
|
+
#
|
154
|
+
# @return [Operation]
|
155
|
+
#
|
156
|
+
# @example
|
157
|
+
# bulk_request.(client, <<~QUERY).() do |products|
|
158
|
+
# {
|
159
|
+
# products {
|
160
|
+
# edges {
|
161
|
+
# node {
|
162
|
+
# id
|
163
|
+
# handle
|
164
|
+
# }
|
165
|
+
# }
|
166
|
+
# }
|
167
|
+
# }
|
168
|
+
# QUERY
|
169
|
+
# db.transaction do
|
170
|
+
# products.each do |product|
|
171
|
+
# db[:products].insert(
|
172
|
+
# id: product['id'],
|
173
|
+
# handle: product['handle'],
|
174
|
+
# )
|
175
|
+
# end
|
176
|
+
# end
|
177
|
+
# end
|
178
|
+
def call(client, query)
|
179
|
+
ShopifyClient.assert_api_version!('2019-10')
|
180
|
+
|
181
|
+
op = client.graphql(<<~QUERY)['data']['currentBulkOperation']
|
182
|
+
{
|
183
|
+
currentBulkOperation {
|
184
|
+
id
|
185
|
+
status
|
186
|
+
url
|
187
|
+
}
|
188
|
+
}
|
189
|
+
QUERY
|
190
|
+
|
191
|
+
case op&.fetch('status')
|
192
|
+
when 'CANCELING'
|
193
|
+
Operation.new(client, op['id']).poll_until(['CANCELED'])
|
194
|
+
when 'CREATED', 'RUNNING'
|
195
|
+
Operation.new(client, op['id']).cancel
|
196
|
+
end
|
197
|
+
|
198
|
+
id = client.graphql(<<~QUERY).data['data']['bulkOperationRunQuery']['bulkOperation']['id']
|
199
|
+
mutation {
|
200
|
+
bulkOperationRunQuery(
|
201
|
+
query: """
|
202
|
+
#{query}
|
203
|
+
"""
|
204
|
+
) {
|
205
|
+
bulkOperation {
|
206
|
+
id
|
207
|
+
}
|
208
|
+
userErrors {
|
209
|
+
field
|
210
|
+
message
|
211
|
+
}
|
212
|
+
}
|
213
|
+
}
|
214
|
+
QUERY
|
215
|
+
|
216
|
+
Operation.new(client, id)
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|