shopify-client 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +342 -0
  3. data/lib/shopify-client.rb +49 -0
  4. data/lib/shopify-client/authorise.rb +38 -0
  5. data/lib/shopify-client/bulk_request.rb +219 -0
  6. data/lib/shopify-client/cache/redis_store.rb +28 -0
  7. data/lib/shopify-client/cache/store.rb +67 -0
  8. data/lib/shopify-client/cache/thread_local_store.rb +47 -0
  9. data/lib/shopify-client/cached_request.rb +69 -0
  10. data/lib/shopify-client/client.rb +126 -0
  11. data/lib/shopify-client/client/logging.rb +38 -0
  12. data/lib/shopify-client/client/normalise_path.rb +14 -0
  13. data/lib/shopify-client/cookieless/check_header.rb +28 -0
  14. data/lib/shopify-client/cookieless/decode_session_token.rb +43 -0
  15. data/lib/shopify-client/cookieless/middleware.rb +39 -0
  16. data/lib/shopify-client/create_all_webhooks.rb +23 -0
  17. data/lib/shopify-client/create_webhook.rb +21 -0
  18. data/lib/shopify-client/delete_all_webhooks.rb +22 -0
  19. data/lib/shopify-client/delete_webhook.rb +13 -0
  20. data/lib/shopify-client/error.rb +9 -0
  21. data/lib/shopify-client/parse_link_header.rb +33 -0
  22. data/lib/shopify-client/request.rb +40 -0
  23. data/lib/shopify-client/resource/base.rb +46 -0
  24. data/lib/shopify-client/resource/create.rb +31 -0
  25. data/lib/shopify-client/resource/delete.rb +29 -0
  26. data/lib/shopify-client/resource/read.rb +80 -0
  27. data/lib/shopify-client/resource/update.rb +30 -0
  28. data/lib/shopify-client/response.rb +201 -0
  29. data/lib/shopify-client/response_errors.rb +59 -0
  30. data/lib/shopify-client/response_user_errors.rb +42 -0
  31. data/lib/shopify-client/struct.rb +10 -0
  32. data/lib/shopify-client/throttling/redis_strategy.rb +62 -0
  33. data/lib/shopify-client/throttling/strategy.rb +50 -0
  34. data/lib/shopify-client/throttling/thread_local_strategy.rb +29 -0
  35. data/lib/shopify-client/verify_request.rb +51 -0
  36. data/lib/shopify-client/verify_webhook.rb +24 -0
  37. data/lib/shopify-client/version.rb +5 -0
  38. data/lib/shopify-client/webhook.rb +32 -0
  39. data/lib/shopify-client/webhook_list.rb +50 -0
  40. 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