shopify_api-graphql-tiny 0.1.0 → 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: 4c46728d688b8edf620e1d9e5d9f78b32dcb4dde40854996e2a42b66f642596b
4
- data.tar.gz: 5640ff9490907231cb4d2a9bae7d8f4f96460265bfcd6d77572d4f90e2b758d7
3
+ metadata.gz: 125818846e86b77345ee59d8e6d03c4600b2aaa56f18e33ec1ae23ecfa90938e
4
+ data.tar.gz: 4b4443e43ff6d37fdbbad202222b6f5b3dff11f8f0a377260898dab06d2209ec
5
5
  SHA512:
6
- metadata.gz: 5e1f350a6269fbb377f29c2342add0ae82b103aa940047995e5f377896e7c0ca0ca555b3eb4f9083bfea8edf8e3f310ae2caf99e7de5ac1b1451b6b12d45158e
7
- data.tar.gz: 9c5fad042bcb6cb2b638178bfe7a0dc5920ca585b1e317c310b25be9ec6c79476c2cf6342bde98ad0c3a467b4864edf37a7cdf9153f4b2ddbe58b49def372b4a
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,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,6 +1,6 @@
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
 
@@ -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.1.0"
6
+ VERSION = "0.1.1"
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 },
@@ -96,21 +98,71 @@ module ShopifyAPI
96
98
  #
97
99
  # === Errors
98
100
  #
99
- # ConnectionError, HTTPError, RateLimitError, GraphQLError
101
+ # ArgumentError, ConnectionError, HTTPError, RateLimitError, GraphQLError
100
102
  #
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
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
104
108
  #
105
109
  # === Returns
106
110
  #
107
111
  # [Hash] The GraphQL response. Unmodified.
108
112
 
109
113
  def execute(q, variables = nil)
114
+ raise ArgumentError, "query required" if q.nil? || q.to_s.strip.empty?
115
+
110
116
  config = retry? ? @options[:retry] || DEFAULT_RETRY_OPTIONS : {}
111
117
  ShopifyAPIRetry::GraphQL.retry(config) { post(q, variables) }
112
118
  end
113
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
+
114
166
  private
115
167
 
116
168
  def retry?
@@ -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.1.0
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-16 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:
@@ -120,10 +124,9 @@ required_rubygems_version: !ruby/object:Gem::Requirement
120
124
  - !ruby/object:Gem::Version
121
125
  version: '0'
122
126
  requirements: []
123
- rubyforge_project:
124
- rubygems_version: 2.7.6
127
+ rubygems_version: 3.1.4
125
128
  signing_key:
126
129
  specification_version: 4
127
- summary: Lightweight, no-nonsense, Shopify Admin API GraphQL client with built-in
128
- retry.
130
+ summary: Lightweight, no-nonsense, Shopify GraphQL Admin API client with built-in
131
+ pagination and retry
129
132
  test_files: []