shopify_api-graphql-tiny 0.0.2 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b83386464ba310b5225b0eaa43f9f2de9c942de2c1a1bd877e65b7aef41c4469
4
- data.tar.gz: d6d6bc0d2a4dc61a0c430b712eadcf52dbd5355c4ed91a3a4c2b44e40933305f
3
+ metadata.gz: 125818846e86b77345ee59d8e6d03c4600b2aaa56f18e33ec1ae23ecfa90938e
4
+ data.tar.gz: 4b4443e43ff6d37fdbbad202222b6f5b3dff11f8f0a377260898dab06d2209ec
5
5
  SHA512:
6
- metadata.gz: 8b15b5bd7a5ce57beb926673041c803f822194ec1c32190a8b80099d4eda987c870084d08ab70d73f956a18b60a12130fb3b3d77846aa56d94346e254864aff6
7
- data.tar.gz: 12235d40cea2cc700c6b74376521c4eec56369f7700232f3773d7ec08d5dd3254cb0e71ad5c2a1087591dcad6547d316841b28fcce0698d68bc097d40765ca62
6
+ metadata.gz: 5418a19e0a24b56d7e587c9a1bde22bf4d1c830639393fa30b371216dc6e8f9824cf1b31f477e6834ae17ec92c0594803a8b0593920a1b7db22655e5fc8439dd
7
+ data.tar.gz: b2ae36edf66dcb67395950e39231cbba3d13f0873b381a78b1cd8404f3e40fd3c7dbc157030c6665cc029ea6800611455cfb19da8fc5dfcd7fc08c3d45304631
data/.env.template CHANGED
@@ -6,4 +6,9 @@
6
6
  #
7
7
  SHOPIFY_DOMAIN=
8
8
  SHOPIFY_TOKEN=
9
+
10
+ # Will result in the creation of a private metadafield
9
11
  SHOPIFY_CUSTOMER_ID=
12
+
13
+ # Must have more than 1 variant
14
+ SHOPIFY_PRODUCT_ID=
@@ -11,10 +11,11 @@ jobs:
11
11
  SHOPIFY_DOMAIN: "${{ secrets.SHOPIFY_DOMAIN }}"
12
12
  SHOPIFY_TOKEN: "${{ secrets.SHOPIFY_TOKEN }}"
13
13
  SHOPIFY_CUSTOMER_ID: "${{ secrets.SHOPIFY_CUSTOMER_ID }}"
14
+ SHOPIFY_PRODUCT_ID: "${{ secrets.SHOPIFY_PRODUCT_ID }}"
14
15
 
15
16
  strategy:
16
17
  matrix:
17
- ruby: ["3.1", "3.0", "2.7.2", "2.6.6", "2.5.8", "2.4.10"]
18
+ ruby: ["3.2", "3.1", "3.0", "2.7.2", "2.6.6", "2.5.8", "2.4.10"]
18
19
 
19
20
  steps:
20
21
  - uses: actions/checkout@v2
data/Changes CHANGED
@@ -1,3 +1,12 @@
1
+ 2023-02-12 v0.1.1
2
+ --------------------
3
+ * Add pagination support
4
+
5
+ 2022-06-16 v0.1.0
6
+ --------------------
7
+ * Make RateLimitError a subclass of GraphQLError
8
+ * Set HTTP user-agent header
9
+
1
10
  2022-06-08 v0.0.2
2
11
  --------------------
3
12
  * Add GraphQLError#response to return the parsed GQL response body
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # ShopifyAPI::GraphQL::Tiny
2
2
 
3
- Lightweight, no-nonsense, Shopify GraphQL Admin API client with built-in retry.
3
+ Lightweight, no-nonsense, Shopify GraphQL Admin API client with built-in pagination and retry.
4
4
 
5
- ![CI](https://github.com/ScreenStaring/shopify_api-graphql-tiny/actions/workflows/ci.yml/badge.svg)
5
+ [![CI](https://github.com/ScreenStaring/shopify_api-graphql-tiny/actions/workflows/ci.yml/badge.svg)](https://github.com/ScreenStaring/shopify_api-graphql-tiny/actions)
6
6
 
7
7
  ## Usage
8
8
 
@@ -10,6 +10,8 @@ Lightweight, no-nonsense, Shopify GraphQL Admin API client with built-in retry.
10
10
  require "shopify_api/graphql/tiny"
11
11
 
12
12
  gql = ShopifyAPI::GraphQL::Tiny.new("my-shop", token)
13
+
14
+ # Automatically retried
13
15
  result = gql.execute(<<-GQL, :id => "gid://shopify/Customer/1283599123")
14
16
  query findCustomer($id: ID!) {
15
17
  customer(id: $id) {
@@ -33,6 +35,8 @@ p customer["tags"]
33
35
  p customer.dig("metafields", "edges", 0, "node")["value"]
34
36
 
35
37
  updates = { :id => customer["id"], :tags => customer["tags"] + %w[foo bar] }
38
+
39
+ # Automatically retried as well
36
40
  result = gql.execute(<<-GQL, :input => updates)
37
41
  mutation customerUpdate($input: CustomerInput!) {
38
42
  customerUpdate(input: $input) {
@@ -50,7 +54,129 @@ GQL
50
54
  p result.dig("data", "customerUpdate", "userErrors")
51
55
  ```
52
56
 
53
- See [the docs](https://rdoc.info/gems/shopify_api-graphql-tiny) for complete documentation.
57
+ ### Pagination
58
+
59
+ In addition to built-in request retry `ShopifyAPI::GraphQL::Tiny` also builds in support for pagination.
60
+
61
+ Using pagination requires you to include [the Shopify `PageInfo` object](https://shopify.dev/api/admin-graphql/2022-10/objects/PageInfo)
62
+ in your queries and wrap them in a function that accepts a page/cursor argument.
63
+
64
+ The pager's `#execute` is like the non-paginated `#execute` method and accepts additional, non-pagination query arguments:
65
+
66
+ ```rb
67
+ pager.execute(query, :foo => 123)
68
+ ```
69
+
70
+ And it accepts a block which will be passed each page returned by the query:
71
+
72
+ ```rb
73
+ pager.execute(query, :foo => 123) do |page|
74
+ # do something with each page
75
+ end
76
+ ```
77
+
78
+ #### `after` Pagination
79
+
80
+ To use `after` pagination, i.e., to paginate forward, your query must:
81
+
82
+ - Make the page/cursor argument optional
83
+ - Include `PageInfo`'s `hasNextPage` and `endCursor` fields
84
+
85
+ For example:
86
+
87
+ ```rb
88
+ FIND_ORDERS = <<-GQL
89
+ query findOrders($after: String) {
90
+ orders(first: 10 after: $after) {
91
+ pageInfo {
92
+ hasNextPage
93
+ endCursor
94
+ }
95
+ edges {
96
+ node {
97
+ id
98
+ email
99
+ }
100
+ }
101
+ }
102
+ }
103
+ GQL
104
+
105
+ pager = gql.paginate # This is the same as gql.paginate(:after)
106
+ pager.execute(FIND_ORDERS) do |page|
107
+ orders = page.dig("data", "orders", "edges")
108
+ orders.each do |order|
109
+ # ...
110
+ end
111
+ end
112
+ ```
113
+
114
+ By default it is assumed your GraphQL query uses a variable named `$after`. You can specify a different name using the `:variable`
115
+ option:
116
+
117
+ ```rb
118
+ pager = gql.paginate(:after, :variable => "yourVariable")
119
+ ```
120
+
121
+ #### `before` Pagination
122
+
123
+ To use `before` pagination, i.e. to paginate backward, your query must:
124
+
125
+ - Make the page/cursor argument **required**
126
+ - Include the `PageInfo`'s `hasPreviousPage` and `startCursor` fields
127
+ - Specify the `:before` argument to `#paginate`
128
+
129
+ For example:
130
+
131
+ ```rb
132
+ FIND_ORDERS = <<-GQL
133
+ query findOrders($before: String) {
134
+ orders(last: 10 before: $before) {
135
+ pageInfo {
136
+ hasPreviousPage
137
+ startCursor
138
+ }
139
+ edges {
140
+ node {
141
+ id
142
+ email
143
+ }
144
+ }
145
+ }
146
+ }
147
+ GQL
148
+
149
+ pager = gql.paginate(:before)
150
+ pager.execute(FIND_ORDERS) do |page|
151
+ # ...
152
+ end
153
+ ```
154
+
155
+ By default it is assumed your GraphQL query uses a variable named `$before`. You can specify a different name using the `:variable`
156
+ option:
157
+
158
+ ```rb
159
+ pager = gql.paginate(:before, :variable => "yourVariable")
160
+ ```
161
+
162
+ #### Response Pagination Data
163
+
164
+ By default `ShopifyAPI::GraphQL::Tiny` will use the first `pageInfo` block with a next or previous page it finds
165
+ in the GraphQL response. If necessary you can specify an explicit location for the `pageInfo` block:
166
+
167
+ ```rb
168
+ pager = gql.paginate(:after => %w[some path to it])
169
+ pager.execute(query) { |page| }
170
+
171
+ pager = gql.paginate(:after => ->(data) { data.dig("some", "path", "to", "it") })
172
+ pager.execute(query) { |page| }
173
+ ```
174
+
175
+ The `"data"` and `"pageInfo"` keys are automatically added if not provided.
176
+
177
+ ### Automatically Retrying Failed Requests
178
+
179
+ See [the docs](https://rubydoc.info/gems/shopify_api-graphql-tiny) for more information.
54
180
 
55
181
  ## Testing
56
182
 
@@ -59,6 +185,7 @@ See [the docs](https://rdoc.info/gems/shopify_api-graphql-tiny) for complete doc
59
185
  ## See Also
60
186
 
61
187
  - [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
188
+ - [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
62
189
  - [ShopifyAPIRetry](https://github.com/ScreenStaring/shopify_api_retry) - Retry a ShopifyAPI request if rate-limited or other errors occur (REST and GraphQL APIs)
63
190
 
64
191
  ## License
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ShopifyAPI
2
4
  module GraphQL
3
5
  class Tiny
4
- VERSION = "0.0.2"
6
+ VERSION = "0.1.1"
5
7
  end
6
8
  end
7
9
  end
@@ -24,6 +24,7 @@ module ShopifyAPI
24
24
  end
25
25
  end
26
26
 
27
+ RateLimitError = Class.new(GraphQLError)
27
28
 
28
29
  class HTTPError < Error
29
30
  attr_reader :code
@@ -34,14 +35,7 @@ module ShopifyAPI
34
35
  end
35
36
  end
36
37
 
37
- class RateLimitError < Error
38
- attr_reader :response
39
-
40
- def initialize(message, response)
41
- super(message)
42
- @response = response
43
- end
44
- end
38
+ USER_AGENT = "ShopifyAPI::GraphQL::Tiny v#{VERSION} (Ruby v#{RUBY_VERSION})"
45
39
 
46
40
  SHOPIFY_DOMAIN = ".myshopify.com"
47
41
 
@@ -49,6 +43,8 @@ module ShopifyAPI
49
43
  QUERY_COST_HEADER = "X-GraphQL-Cost-Include-Fields"
50
44
 
51
45
  DEFAULT_HEADERS = { "Content-Type" => "application/json" }.freeze
46
+
47
+ # Retry rules to be used for all instances if no rules are specified via the +:retry+ option when creating an instance
52
48
  DEFAULT_RETRY_OPTIONS = {
53
49
  ConnectionError => { :wait => 3, :tries => 20 },
54
50
  GraphQLError => { :wait => 3, :tries => 20 },
@@ -71,7 +67,7 @@ module ShopifyAPI
71
67
  #
72
68
  # [:retry (Boolean|Hash)] +Hash+ can be retry config options. For the format see {ShopifyAPIRetry}[https://github.com/ScreenStaring/shopify_api_retry/#usage]. Defaults to +true+
73
69
  # [:version (String)] Shopify API version to use. Defaults to the latest version.
74
- # [:debug (Boolean)] Output the HTTP request/response to +STDERR+. Defaults to +false+.
70
+ # [:debug (Boolean|IO)] Output the HTTP request/response to +STDERR+ or to its value if it's an +IO+. Defaults to +false+.
75
71
  #
76
72
  # === Errors
77
73
  #
@@ -102,21 +98,71 @@ module ShopifyAPI
102
98
  #
103
99
  # === Errors
104
100
  #
105
- # ConnectionError, HTTPError, RateLimitError, GraphQLError
101
+ # ArgumentError, ConnectionError, HTTPError, RateLimitError, GraphQLError
106
102
  #
107
- # * An HTTPError is raised of the response does not have 200 status code
108
- # * A RateLimitError is raised if rate-limited and retries are disabled or if still rate-limited after the configured number of retry attempts
109
- # * A GraphQLError is raised if the response contains an +errors+ property that is not a rate-limit error
103
+ # * An ShopifyAPI::GraphQL::Tiny::HTTPError is raised of the response does not have 200 status code
104
+ # * A ShopifyAPI::GraphQL::Tiny::RateLimitError is raised if rate-limited and retries are disabled or if still
105
+ # rate-limited after the configured number of retry attempts
106
+ # * A ShopifyAPI::GraphQL::Tiny::GraphQLError is raised if the response contains an +errors+ property that is
107
+ # not a rate-limit error
110
108
  #
111
109
  # === Returns
112
110
  #
113
111
  # [Hash] The GraphQL response. Unmodified.
114
112
 
115
113
  def execute(q, variables = nil)
114
+ raise ArgumentError, "query required" if q.nil? || q.to_s.strip.empty?
115
+
116
116
  config = retry? ? @options[:retry] || DEFAULT_RETRY_OPTIONS : {}
117
117
  ShopifyAPIRetry::GraphQL.retry(config) { post(q, variables) }
118
118
  end
119
119
 
120
+ ##
121
+ # Create a pager to execute a paginated query:
122
+ #
123
+ # pager = gql.paginate # This is the same as gql.paginate(:after)
124
+ # pager.execute(query, :id => id) do |page|
125
+ # page.dig("data", "product", "title")
126
+ # end
127
+ #
128
+ # The block is called for each page.
129
+ #
130
+ # Using pagination requires you to include the
131
+ # {PageInfo}[https://shopify.dev/api/admin-graphql/2022-10/objects/PageInfo]
132
+ # object in your queries and wrap them in a function that accepts a page/cursor argument.
133
+ # See the README for more information.
134
+ #
135
+ # === Arguments
136
+ #
137
+ # [direction (Symbol)] The direction to paginate, either +:after+ or +:before+. Optional, defaults to +:after:+
138
+ # [options (Hash)] Pagination options. Optional.
139
+ #
140
+ # === Options
141
+ #
142
+ # [:after (Array|Proc)] The location of {PageInfo}[https://shopify.dev/api/admin-graphql/2022-10/objects/PageInfo]
143
+ # block.
144
+ #
145
+ # An +Array+ will be passed directly to <code>Hash#dig</code>. A +TypeError+ resulting
146
+ # from the +#dig+ call will be raised as an +ArgumentError+.
147
+ #
148
+ # The <code>"data"</code> and <code>"pageInfo"</code> keys are automatically added if not provided.
149
+ #
150
+ # A +Proc+ must accept the GraphQL response +Hash+ as its argument and must return the
151
+ # +pageInfo+ block to use for pagination.
152
+ #
153
+ # [:before (Array|Proc)] See the +:after+ option
154
+ # [:variable (String)] Name of the GraphQL variable to use as the "page" argument.
155
+ # Defaults to <code>"before"</code> or <code>"after"</code>, depending on the pagination
156
+ # direction.
157
+ #
158
+ # === Errors
159
+ #
160
+ # ArgumentError
161
+
162
+ def paginate(*options)
163
+ Pager.new(self, options)
164
+ end
165
+
120
166
  private
121
167
 
122
168
  def retry?
@@ -138,12 +184,18 @@ module ShopifyAPI
138
184
 
139
185
  post = Net::HTTP::Post.new(@endpoint.path)
140
186
  post.body = params.to_json
187
+ post["User-Agent"] = USER_AGENT
141
188
 
142
189
  @headers.each { |k,v| post[k] = v }
143
190
 
144
191
  request = Net::HTTP.new(@endpoint.host, @endpoint.port)
145
192
  request.use_ssl = true
146
- request.set_debug_output($stderr) if @options[:debug]
193
+
194
+ if @options[:debug]
195
+ request.set_debug_output(
196
+ @options[:debug].is_a?(IO) ? @options[:debug] : $stderr
197
+ )
198
+ end
147
199
 
148
200
  response = request.start { |http| http.request(post) }
149
201
  rescue => e
@@ -166,6 +218,147 @@ module ShopifyAPI
166
218
  raise GraphQLError.new(prefix + errors, json)
167
219
  end
168
220
  end
221
+
222
+ class Pager # :nodoc:
223
+ NEXT_PAGE_KEYS = {
224
+ :before => %w[hasPreviousPage startCursor].freeze,
225
+ :after => %w[hasNextPage endCursor].freeze
226
+ }.freeze
227
+
228
+ def initialize(gql, *options)
229
+ @gql = gql
230
+ @options = normalize_options(options)
231
+ end
232
+
233
+ def execute(q, variables = nil)
234
+ unless pagination_variable_exists?(q)
235
+ raise ArgumentError, "query does not contain the pagination variable '#{@options[:variable]}'"
236
+ end
237
+
238
+ variables ||= {}
239
+ pagination_finder = @options[@options[:direction]]
240
+
241
+ loop do
242
+ page = @gql.execute(q, variables)
243
+
244
+ yield page
245
+
246
+ cursor = pagination_finder[page]
247
+ break unless cursor
248
+
249
+ next_page_variables = variables.dup
250
+ next_page_variables[@options[:variable]] = cursor
251
+ #break unless next_page_variables != variables
252
+
253
+ variables = next_page_variables
254
+ end
255
+ end
256
+
257
+ private
258
+
259
+ def normalize_options(options)
260
+ normalized = {}
261
+
262
+ options.flatten!
263
+ options.each do |option|
264
+ case option
265
+ when Hash
266
+ normalized.merge!(normalize_hash_option(option))
267
+ when *NEXT_PAGE_KEYS.keys
268
+ normalized[:direction] = option
269
+ else
270
+ raise ArgumentError, "invalid pagination option #{option}"
271
+ end
272
+ end
273
+
274
+ normalized[:direction] ||= :after
275
+ normalized[normalized[:direction]] ||= method(:default_pagination_finder)
276
+
277
+ normalized[:variable] ||= normalized[:direction].to_s
278
+ normalized[:variable] = normalized[:variable].sub(%r{\A\$}, "")
279
+
280
+ normalized
281
+ end
282
+
283
+ def normalize_hash_option(option)
284
+ normalized = option.dup
285
+
286
+ NEXT_PAGE_KEYS.each do |key, _|
287
+ next unless option.include?(key)
288
+
289
+ normalized[:direction] = key
290
+
291
+ case option[key]
292
+ when Proc
293
+ normalized[key] = ->(data) { extract_cursor(option[key][data]) }
294
+ when Array
295
+ path = pagination_path(option[key])
296
+ normalized[key] = ->(data) do
297
+ begin
298
+ extract_cursor(data.dig(*path))
299
+ rescue TypeError => e
300
+ # Use original path in error as not to confuse
301
+ raise ArgumentError, "invalid pagination path #{option[key]}: #{e}"
302
+ end
303
+ end
304
+ else
305
+ raise ArgumentError, "invalid pagination locator #{option[key]}"
306
+ end
307
+ end
308
+
309
+ normalized
310
+ end
311
+
312
+ def pagination_path(user_path)
313
+ path = user_path.dup
314
+
315
+ # No need for this, we check for this key ourselves
316
+ path.pop if path[-1] == "pageInfo"
317
+
318
+ # Must always include this (sigh)
319
+ path.unshift("data") if path[0] != "data"
320
+
321
+ path
322
+ end
323
+
324
+ def pagination_variable_exists?(query)
325
+ name = Regexp.quote(@options[:variable])
326
+ query.match?(%r{\$#{name}\s*:})
327
+ end
328
+
329
+ def extract_cursor(data)
330
+ return unless data.is_a?(Hash)
331
+
332
+ has_next, next_cursor = NEXT_PAGE_KEYS[@options[:direction]]
333
+
334
+ pi = data["pageInfo"]
335
+ return unless pi && pi[has_next]
336
+
337
+ pi[next_cursor]
338
+ end
339
+
340
+ def default_pagination_finder(data)
341
+ cursor = nil
342
+
343
+ case data
344
+ when Hash
345
+ cursor = extract_cursor(data)
346
+ return cursor if cursor
347
+
348
+ data.values.each do |v|
349
+ cursor = default_pagination_finder(v)
350
+ break if cursor
351
+ end
352
+ when Array
353
+ data.each do |v|
354
+ cursor = default_pagination_finder(v)
355
+ break if cursor
356
+ end
357
+ end
358
+
359
+ cursor
360
+ end
361
+ end
169
362
  end
170
363
 
171
364
  GQL = GraphQL
@@ -9,7 +9,7 @@ Gem::Specification.new do |spec|
9
9
  spec.authors = ["Skye Shaw"]
10
10
  spec.email = ["skye.shaw@gmail.com"]
11
11
 
12
- spec.summary = %q{Lightweight, no-nonsense, Shopify Admin API GraphQL client with built-in retry.}
12
+ spec.summary = %q{Lightweight, no-nonsense, Shopify GraphQL Admin API client with built-in pagination and retry}
13
13
  spec.homepage = "https://github.com/ScreenStaring/shopify_api-graphql-tiny"
14
14
  spec.license = "MIT"
15
15
 
@@ -21,6 +21,12 @@ Gem::Specification.new do |spec|
21
21
  spec.bindir = "exe"
22
22
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
23
23
  spec.require_paths = ["lib"]
24
+ spec.metadata = {
25
+ "bug_tracker_uri" => "https://github.com/ScreenStaring/shopify_api-graphql-tiny/issues",
26
+ "changelog_uri" => "https://github.com/ScreenStaring/shopify_api-graphql-tiny/blob/master/Changes",
27
+ "documentation_uri" => "https://rubydoc.info/gems/shopify_api-graphql-tiny",
28
+ "source_code_uri" => "https://github.com/ScreenStaring/shopify_api-graphql-tiny",
29
+ }
24
30
 
25
31
  spec.add_dependency "shopify_api_retry", "~> 0.2"
26
32
  spec.add_development_dependency "webmock", "~> 3.0"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shopify_api-graphql-tiny
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Skye Shaw
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-06-08 00:00:00.000000000 Z
11
+ date: 2023-02-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: shopify_api_retry
@@ -104,7 +104,11 @@ files:
104
104
  homepage: https://github.com/ScreenStaring/shopify_api-graphql-tiny
105
105
  licenses:
106
106
  - MIT
107
- metadata: {}
107
+ metadata:
108
+ bug_tracker_uri: https://github.com/ScreenStaring/shopify_api-graphql-tiny/issues
109
+ changelog_uri: https://github.com/ScreenStaring/shopify_api-graphql-tiny/blob/master/Changes
110
+ documentation_uri: https://rubydoc.info/gems/shopify_api-graphql-tiny
111
+ source_code_uri: https://github.com/ScreenStaring/shopify_api-graphql-tiny
108
112
  post_install_message:
109
113
  rdoc_options: []
110
114
  require_paths:
@@ -123,6 +127,6 @@ requirements: []
123
127
  rubygems_version: 3.1.4
124
128
  signing_key:
125
129
  specification_version: 4
126
- summary: Lightweight, no-nonsense, Shopify Admin API GraphQL client with built-in
127
- retry.
130
+ summary: Lightweight, no-nonsense, Shopify GraphQL Admin API client with built-in
131
+ pagination and retry
128
132
  test_files: []