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.
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