shopify_api-graphql-tiny 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4c46728d688b8edf620e1d9e5d9f78b32dcb4dde40854996e2a42b66f642596b
4
- data.tar.gz: 5640ff9490907231cb4d2a9bae7d8f4f96460265bfcd6d77572d4f90e2b758d7
3
+ metadata.gz: be69f222bb991d00b37c44d8aea2ad9caee713527ce23c60121a401d8d2a8b39
4
+ data.tar.gz: 5a35aaada946e0f0ef1dc50ea7962da2252a7f70a111a911f660b1c8eab3a79a
5
5
  SHA512:
6
- metadata.gz: 5e1f350a6269fbb377f29c2342add0ae82b103aa940047995e5f377896e7c0ca0ca555b3eb4f9083bfea8edf8e3f310ae2caf99e7de5ac1b1451b6b12d45158e
7
- data.tar.gz: 9c5fad042bcb6cb2b638178bfe7a0dc5920ca585b1e317c310b25be9ec6c79476c2cf6342bde98ad0c3a467b4864edf37a7cdf9153f4b2ddbe58b49def372b4a
6
+ metadata.gz: be94db6e155cc11d0136a229e6ee728ac808c655896f5489a657fc4ba82b7d5ab8403989bc356cfaaa617766077220e8234c60ff6d9aa9a651d484490cb161c3
7
+ data.tar.gz: 166c19ddde4a76d8d216bffac16d3844c9add39f9884eb70adac3477ce5b222e19cba820c1bae1204f58424239be38a1ec063d8e75a977f337f0d5df5afc6452
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,7 @@
1
+ 2023-02-12 v0.1.1
2
+ --------------------
3
+ * Add pagination support
4
+
1
5
  2022-06-16 v0.1.0
2
6
  --------------------
3
7
  * Make RateLimitError a subclass of GraphQLError
data/README.md CHANGED
@@ -1,15 +1,37 @@
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
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
+ ## Installation
8
+
9
+ Add this line to your application's `Gemfile`:
10
+
11
+ ```ruby
12
+ gem "shopify_api-graphql-tiny"
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ ```sh
18
+ bundle
19
+ ```
20
+
21
+ Or install it yourself as:
22
+
23
+ ```sh
24
+ gem install shopify_api-graphql-tiny
25
+ ```
26
+
7
27
  ## Usage
8
28
 
9
29
  ```rb
10
30
  require "shopify_api/graphql/tiny"
11
31
 
12
32
  gql = ShopifyAPI::GraphQL::Tiny.new("my-shop", token)
33
+
34
+ # Automatically retried
13
35
  result = gql.execute(<<-GQL, :id => "gid://shopify/Customer/1283599123")
14
36
  query findCustomer($id: ID!) {
15
37
  customer(id: $id) {
@@ -33,6 +55,8 @@ p customer["tags"]
33
55
  p customer.dig("metafields", "edges", 0, "node")["value"]
34
56
 
35
57
  updates = { :id => customer["id"], :tags => customer["tags"] + %w[foo bar] }
58
+
59
+ # Automatically retried as well
36
60
  result = gql.execute(<<-GQL, :input => updates)
37
61
  mutation customerUpdate($input: CustomerInput!) {
38
62
  customerUpdate(input: $input) {
@@ -50,7 +74,131 @@ GQL
50
74
  p result.dig("data", "customerUpdate", "userErrors")
51
75
  ```
52
76
 
53
- See [the docs](https://rdoc.info/gems/shopify_api-graphql-tiny) for complete documentation.
77
+ ### Pagination
78
+
79
+ In addition to built-in request retry `ShopifyAPI::GraphQL::Tiny` also builds in support for pagination.
80
+
81
+ Using pagination requires you to include [the Shopify `PageInfo` object](https://shopify.dev/api/admin-graphql/2022-10/objects/PageInfo)
82
+ in your queries and wrap them in a function that accepts a page/cursor argument.
83
+
84
+ The pager's `#execute` is like the non-paginated `#execute` method and accepts additional, non-pagination query arguments:
85
+
86
+ ```rb
87
+ gql = ShopifyAPI::GraphQL::Tiny.new("my-shop", token)
88
+ pager = gql.paginate
89
+ pager.execute(query, :foo => 123)
90
+ ```
91
+
92
+ And it accepts a block which will be passed each page returned by the query:
93
+
94
+ ```rb
95
+ pager.execute(query, :foo => 123) do |page|
96
+ # do something with each page
97
+ end
98
+ ```
99
+
100
+ #### `after` Pagination
101
+
102
+ To use `after` pagination, i.e., to paginate forward, your query must:
103
+
104
+ - Make the page/cursor argument optional
105
+ - Include `PageInfo`'s `hasNextPage` and `endCursor` fields
106
+
107
+ For example:
108
+
109
+ ```rb
110
+ FIND_ORDERS = <<-GQL
111
+ query findOrders($after: String) {
112
+ orders(first: 10 after: $after) {
113
+ pageInfo {
114
+ hasNextPage
115
+ endCursor
116
+ }
117
+ edges {
118
+ node {
119
+ id
120
+ email
121
+ }
122
+ }
123
+ }
124
+ }
125
+ GQL
126
+
127
+ pager = gql.paginate # This is the same as gql.paginate(:after)
128
+ pager.execute(FIND_ORDERS) do |page|
129
+ orders = page.dig("data", "orders", "edges")
130
+ orders.each do |order|
131
+ # ...
132
+ end
133
+ end
134
+ ```
135
+
136
+ By default it is assumed your GraphQL query uses a variable named `$after`. You can specify a different name using the `:variable`
137
+ option:
138
+
139
+ ```rb
140
+ pager = gql.paginate(:after, :variable => "yourVariable")
141
+ ```
142
+
143
+ #### `before` Pagination
144
+
145
+ To use `before` pagination, i.e. to paginate backward, your query must:
146
+
147
+ - Make the page/cursor argument **required**
148
+ - Include the `PageInfo`'s `hasPreviousPage` and `startCursor` fields
149
+ - Specify the `:before` argument to `#paginate`
150
+
151
+ For example:
152
+
153
+ ```rb
154
+ FIND_ORDERS = <<-GQL
155
+ query findOrders($before: String) {
156
+ orders(last: 10 before: $before) {
157
+ pageInfo {
158
+ hasPreviousPage
159
+ startCursor
160
+ }
161
+ edges {
162
+ node {
163
+ id
164
+ email
165
+ }
166
+ }
167
+ }
168
+ }
169
+ GQL
170
+
171
+ pager = gql.paginate(:before)
172
+ pager.execute(FIND_ORDERS) do |page|
173
+ # ...
174
+ end
175
+ ```
176
+
177
+ By default it is assumed your GraphQL query uses a variable named `$before`. You can specify a different name using the `:variable`
178
+ option:
179
+
180
+ ```rb
181
+ pager = gql.paginate(:before, :variable => "yourVariable")
182
+ ```
183
+
184
+ #### Response Pagination Data
185
+
186
+ By default `ShopifyAPI::GraphQL::Tiny` will use the first `pageInfo` block with a next or previous page it finds
187
+ in the GraphQL response. If necessary you can specify an explicit location for the `pageInfo` block:
188
+
189
+ ```rb
190
+ pager = gql.paginate(:after => %w[some path to it])
191
+ pager.execute(query) { |page| }
192
+
193
+ pager = gql.paginate(:after => ->(data) { data.dig("some", "path", "to", "it") })
194
+ pager.execute(query) { |page| }
195
+ ```
196
+
197
+ The `"data"` and `"pageInfo"` keys are automatically added if not provided.
198
+
199
+ ### Automatically Retrying Failed Requests
200
+
201
+ See [the docs](https://rubydoc.info/gems/shopify_api-graphql-tiny) for more information.
54
202
 
55
203
  ## Testing
56
204
 
@@ -59,6 +207,7 @@ See [the docs](https://rdoc.info/gems/shopify_api-graphql-tiny) for complete doc
59
207
  ## See Also
60
208
 
61
209
  - [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
210
+ - [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
211
  - [ShopifyAPIRetry](https://github.com/ScreenStaring/shopify_api_retry) - Retry a ShopifyAPI request if rate-limited or other errors occur (REST and GraphQL APIs)
63
212
 
64
213
  ## 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.1.0"
6
+ VERSION = "0.2.0"
5
7
  end
6
8
  end
7
9
  end
@@ -43,6 +43,8 @@ module ShopifyAPI
43
43
  QUERY_COST_HEADER = "X-GraphQL-Cost-Include-Fields"
44
44
 
45
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
46
48
  DEFAULT_RETRY_OPTIONS = {
47
49
  ConnectionError => { :wait => 3, :tries => 20 },
48
50
  GraphQLError => { :wait => 3, :tries => 20 },
@@ -84,6 +86,10 @@ module ShopifyAPI
84
86
  @headers[QUERY_COST_HEADER] = "true" if retry?
85
87
 
86
88
  @endpoint = URI(sprintf(ENDPOINT, @domain, !@options[:version].to_s.strip.empty? ? "/#{@options[:version]}" : ""))
89
+
90
+ if @options[:retry].is_a?(Hash)
91
+ warn "DEPRECATION WARNING from #{self.class}: specifying retry options as a Hash via the :retry option is deprecated and will be removed in v1.0"
92
+ end
87
93
  end
88
94
 
89
95
  #
@@ -96,21 +102,71 @@ module ShopifyAPI
96
102
  #
97
103
  # === Errors
98
104
  #
99
- # ConnectionError, HTTPError, RateLimitError, GraphQLError
105
+ # ArgumentError, ConnectionError, HTTPError, RateLimitError, GraphQLError
100
106
  #
101
- # * An HTTPError is raised of the response does not have 200 status code
102
- # * A RateLimitError is raised if rate-limited and retries are disabled or if still rate-limited after the configured number of retry attempts
103
- # * A GraphQLError is raised if the response contains an +errors+ property that is not a rate-limit error
107
+ # * An ShopifyAPI::GraphQL::Tiny::HTTPError is raised of the response does not have 200 status code
108
+ # * A ShopifyAPI::GraphQL::Tiny::RateLimitError is raised if rate-limited and retries are disabled or if still
109
+ # rate-limited after the configured number of retry attempts
110
+ # * A ShopifyAPI::GraphQL::Tiny::GraphQLError is raised if the response contains an +errors+ property that is
111
+ # not a rate-limit error
104
112
  #
105
113
  # === Returns
106
114
  #
107
115
  # [Hash] The GraphQL response. Unmodified.
108
116
 
109
117
  def execute(q, variables = nil)
118
+ raise ArgumentError, "query required" if q.nil? || q.to_s.strip.empty?
119
+
110
120
  config = retry? ? @options[:retry] || DEFAULT_RETRY_OPTIONS : {}
111
121
  ShopifyAPIRetry::GraphQL.retry(config) { post(q, variables) }
112
122
  end
113
123
 
124
+ ##
125
+ # Create a pager to execute a paginated query:
126
+ #
127
+ # pager = gql.paginate # This is the same as gql.paginate(:after)
128
+ # pager.execute(query, :id => id) do |page|
129
+ # page.dig("data", "product", "title")
130
+ # end
131
+ #
132
+ # The block is called for each page.
133
+ #
134
+ # Using pagination requires you to include the
135
+ # {PageInfo}[https://shopify.dev/api/admin-graphql/2022-10/objects/PageInfo]
136
+ # object in your queries and wrap them in a function that accepts a page/cursor argument.
137
+ # See the README for more information.
138
+ #
139
+ # === Arguments
140
+ #
141
+ # [direction (Symbol)] The direction to paginate, either +:after+ or +:before+. Optional, defaults to +:after:+
142
+ # [options (Hash)] Pagination options. Optional.
143
+ #
144
+ # === Options
145
+ #
146
+ # [:after (Array|Proc)] The location of {PageInfo}[https://shopify.dev/api/admin-graphql/2022-10/objects/PageInfo]
147
+ # block.
148
+ #
149
+ # An +Array+ will be passed directly to <code>Hash#dig</code>. A +TypeError+ resulting
150
+ # from the +#dig+ call will be raised as an +ArgumentError+.
151
+ #
152
+ # The <code>"data"</code> and <code>"pageInfo"</code> keys are automatically added if not provided.
153
+ #
154
+ # A +Proc+ must accept the GraphQL response +Hash+ as its argument and must return the
155
+ # +pageInfo+ block to use for pagination.
156
+ #
157
+ # [:before (Array|Proc)] See the +:after+ option
158
+ # [:variable (String)] Name of the GraphQL variable to use as the "page" argument.
159
+ # Defaults to <code>"before"</code> or <code>"after"</code>, depending on the pagination
160
+ # direction.
161
+ #
162
+ # === Errors
163
+ #
164
+ # ArgumentError
165
+
166
+ def paginate(*options)
167
+ Pager.new(self, options)
168
+ end
169
+
114
170
  private
115
171
 
116
172
  def retry?
@@ -166,6 +222,147 @@ module ShopifyAPI
166
222
  raise GraphQLError.new(prefix + errors, json)
167
223
  end
168
224
  end
225
+
226
+ class Pager # :nodoc:
227
+ NEXT_PAGE_KEYS = {
228
+ :before => %w[hasPreviousPage startCursor].freeze,
229
+ :after => %w[hasNextPage endCursor].freeze
230
+ }.freeze
231
+
232
+ def initialize(gql, *options)
233
+ @gql = gql
234
+ @options = normalize_options(options)
235
+ end
236
+
237
+ def execute(q, variables = nil)
238
+ unless pagination_variable_exists?(q)
239
+ raise ArgumentError, "query does not contain the pagination variable '#{@options[:variable]}'"
240
+ end
241
+
242
+ variables ||= {}
243
+ pagination_finder = @options[@options[:direction]]
244
+
245
+ loop do
246
+ page = @gql.execute(q, variables)
247
+
248
+ yield page
249
+
250
+ cursor = pagination_finder[page]
251
+ break unless cursor
252
+
253
+ next_page_variables = variables.dup
254
+ next_page_variables[@options[:variable]] = cursor
255
+ #break unless next_page_variables != variables
256
+
257
+ variables = next_page_variables
258
+ end
259
+ end
260
+
261
+ private
262
+
263
+ def normalize_options(options)
264
+ normalized = {}
265
+
266
+ options.flatten!
267
+ options.each do |option|
268
+ case option
269
+ when Hash
270
+ normalized.merge!(normalize_hash_option(option))
271
+ when *NEXT_PAGE_KEYS.keys
272
+ normalized[:direction] = option
273
+ else
274
+ raise ArgumentError, "invalid pagination option #{option}"
275
+ end
276
+ end
277
+
278
+ normalized[:direction] ||= :after
279
+ normalized[normalized[:direction]] ||= method(:default_pagination_finder)
280
+
281
+ normalized[:variable] ||= normalized[:direction].to_s
282
+ normalized[:variable] = normalized[:variable].sub(%r{\A\$}, "")
283
+
284
+ normalized
285
+ end
286
+
287
+ def normalize_hash_option(option)
288
+ normalized = option.dup
289
+
290
+ NEXT_PAGE_KEYS.each do |key, _|
291
+ next unless option.include?(key)
292
+
293
+ normalized[:direction] = key
294
+
295
+ case option[key]
296
+ when Proc
297
+ normalized[key] = ->(data) { extract_cursor(option[key][data]) }
298
+ when Array
299
+ path = pagination_path(option[key])
300
+ normalized[key] = ->(data) do
301
+ begin
302
+ extract_cursor(data.dig(*path))
303
+ rescue TypeError => e
304
+ # Use original path in error as not to confuse
305
+ raise ArgumentError, "invalid pagination path #{option[key]}: #{e}"
306
+ end
307
+ end
308
+ else
309
+ raise ArgumentError, "invalid pagination locator #{option[key]}"
310
+ end
311
+ end
312
+
313
+ normalized
314
+ end
315
+
316
+ def pagination_path(user_path)
317
+ path = user_path.dup
318
+
319
+ # No need for this, we check for this key ourselves
320
+ path.pop if path[-1] == "pageInfo"
321
+
322
+ # Must always include this (sigh)
323
+ path.unshift("data") if path[0] != "data"
324
+
325
+ path
326
+ end
327
+
328
+ def pagination_variable_exists?(query)
329
+ name = Regexp.quote(@options[:variable])
330
+ query.match?(%r{\$#{name}\s*:})
331
+ end
332
+
333
+ def extract_cursor(data)
334
+ return unless data.is_a?(Hash)
335
+
336
+ has_next, next_cursor = NEXT_PAGE_KEYS[@options[:direction]]
337
+
338
+ pi = data["pageInfo"]
339
+ return unless pi && pi[has_next]
340
+
341
+ pi[next_cursor]
342
+ end
343
+
344
+ def default_pagination_finder(data)
345
+ cursor = nil
346
+
347
+ case data
348
+ when Hash
349
+ cursor = extract_cursor(data)
350
+ return cursor if cursor
351
+
352
+ data.values.each do |v|
353
+ cursor = default_pagination_finder(v)
354
+ break if cursor
355
+ end
356
+ when Array
357
+ data.each do |v|
358
+ cursor = default_pagination_finder(v)
359
+ break if cursor
360
+ end
361
+ end
362
+
363
+ cursor
364
+ end
365
+ end
169
366
  end
170
367
 
171
368
  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,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shopify_api-graphql-tiny
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Skye Shaw
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2022-06-16 00:00:00.000000000 Z
10
+ date: 2026-01-20 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: shopify_api_retry
@@ -80,7 +79,6 @@ dependencies:
80
79
  - - "~>"
81
80
  - !ruby/object:Gem::Version
82
81
  version: '3.0'
83
- description:
84
82
  email:
85
83
  - skye.shaw@gmail.com
86
84
  executables: []
@@ -104,8 +102,11 @@ files:
104
102
  homepage: https://github.com/ScreenStaring/shopify_api-graphql-tiny
105
103
  licenses:
106
104
  - MIT
107
- metadata: {}
108
- post_install_message:
105
+ metadata:
106
+ bug_tracker_uri: https://github.com/ScreenStaring/shopify_api-graphql-tiny/issues
107
+ changelog_uri: https://github.com/ScreenStaring/shopify_api-graphql-tiny/blob/master/Changes
108
+ documentation_uri: https://rubydoc.info/gems/shopify_api-graphql-tiny
109
+ source_code_uri: https://github.com/ScreenStaring/shopify_api-graphql-tiny
109
110
  rdoc_options: []
110
111
  require_paths:
111
112
  - lib
@@ -120,10 +121,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
120
121
  - !ruby/object:Gem::Version
121
122
  version: '0'
122
123
  requirements: []
123
- rubyforge_project:
124
- rubygems_version: 2.7.6
125
- signing_key:
124
+ rubygems_version: 3.6.2
126
125
  specification_version: 4
127
- summary: Lightweight, no-nonsense, Shopify Admin API GraphQL client with built-in
128
- retry.
126
+ summary: Lightweight, no-nonsense, Shopify GraphQL Admin API client with built-in
127
+ pagination and retry
129
128
  test_files: []