shopify_api-graphql-tiny 0.1.0 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
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: []