shopify-cli 2.19.0 → 2.20.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.yaml +2 -1
  3. data/.github/ISSUE_TEMPLATE/config.yml +9 -0
  4. data/CHANGELOG.md +16 -2
  5. data/Gemfile.lock +1 -1
  6. data/README.md +7 -6
  7. data/docs/users/installation.md +1 -1
  8. data/lib/project_types/extension/messages/messages.rb +1 -1
  9. data/lib/project_types/extension/tasks/fetch_specifications.rb +4 -1
  10. data/lib/project_types/theme/commands/push.rb +3 -1
  11. data/lib/project_types/theme/commands/serve.rb +1 -0
  12. data/lib/project_types/theme/messages/messages.rb +38 -1
  13. data/lib/shopify_cli/assets/post_auth_page/index.html.erb +34 -0
  14. data/lib/shopify_cli/assets/post_auth_page/style.css +58 -0
  15. data/lib/shopify_cli/identity_auth/servlet.rb +4 -20
  16. data/lib/shopify_cli/messages/messages.rb +6 -8
  17. data/lib/shopify_cli/theme/dev_server/hot-reload-no-script.html +27 -0
  18. data/lib/shopify_cli/theme/dev_server/hot-reload.js +16 -4
  19. data/lib/shopify_cli/theme/dev_server/hot_reload.rb +2 -0
  20. data/lib/shopify_cli/theme/dev_server.rb +3 -2
  21. data/lib/shopify_cli/theme/file.rb +5 -0
  22. data/lib/shopify_cli/theme/syncer/json_update_handler.rb +16 -6
  23. data/lib/shopify_cli/theme/syncer/operation.rb +7 -6
  24. data/lib/shopify_cli/theme/syncer/unsupported_script_warning.rb +90 -0
  25. data/lib/shopify_cli/theme/syncer.rb +73 -29
  26. data/lib/shopify_cli/theme/theme_admin_api.rb +16 -11
  27. data/lib/shopify_cli/theme/theme_admin_api_throttler/bulk.rb +102 -0
  28. data/lib/shopify_cli/theme/theme_admin_api_throttler/bulk_job.rb +75 -0
  29. data/lib/shopify_cli/theme/theme_admin_api_throttler/errors.rb +7 -0
  30. data/lib/shopify_cli/theme/theme_admin_api_throttler/put_request.rb +52 -0
  31. data/lib/shopify_cli/theme/theme_admin_api_throttler/request_parser.rb +39 -0
  32. data/lib/shopify_cli/theme/theme_admin_api_throttler/response_parser.rb +21 -0
  33. data/lib/shopify_cli/theme/theme_admin_api_throttler.rb +62 -0
  34. data/lib/shopify_cli/version.rb +1 -1
  35. metadata +14 -2
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyCLI
4
+ module Theme
5
+ class Syncer
6
+ class UnsupportedScriptWarning
7
+ attr_reader :ctx
8
+
9
+ def initialize(ctx, file)
10
+ @ctx = ctx
11
+ @file = file
12
+ end
13
+
14
+ def to_s
15
+ "\n\n#{occurrences} #{long_text}"
16
+ end
17
+
18
+ private
19
+
20
+ def occurrences
21
+ warnings.map { |w| occurrence(w) }.join("\n")
22
+ end
23
+
24
+ def occurrence(warning)
25
+ line_number = "{{blue: #{warning.line} |}}"
26
+ pointer = pointer_message(warning)
27
+
28
+ <<~OCCURRENCE
29
+ #{line_number} #{warning.line_content}
30
+ #{pointer}
31
+ OCCURRENCE
32
+ end
33
+
34
+ def long_text
35
+ lines_and_columns = warnings.map do |warning|
36
+ message("line_and_column", warning.line, warning.column)
37
+ end
38
+
39
+ message("unsupported_script_text", lines_and_columns.join)
40
+ .split("\n")
41
+ .reduce("") do |text, line|
42
+ # Add indentation in the long text to improve readability
43
+ line = " #{line}"
44
+
45
+ # Inline yellow (otherwise `CLI::UI::Frame` breaks multiline formatting)
46
+ line = "{{yellow:#{line}}}"
47
+
48
+ "#{text}#{line}\n"
49
+ end
50
+ end
51
+
52
+ def pointer_message(warning)
53
+ padding = warning.column + warning.line.to_s.size + 2
54
+ text = message("unsupported_script")
55
+
56
+ "{{yellow:#{" " * padding} ^ {{bold:#{text}}}}}"
57
+ end
58
+
59
+ def message(*args)
60
+ key = args.shift
61
+ @ctx.message("theme.serve.syncer.warnings.#{key}", *args)
62
+ end
63
+
64
+ def warnings
65
+ @warnings ||= @file.warnings.map { |w| Warning.new(@file, w) }
66
+ end
67
+
68
+ class Warning
69
+ attr_reader :line, :column
70
+
71
+ def initialize(file, warning_hash)
72
+ @file = file
73
+ @line = warning_hash["line"].to_i
74
+ @column = warning_hash["column"].to_i
75
+ end
76
+
77
+ def line_content
78
+ file_lines[line - 1]
79
+ end
80
+
81
+ private
82
+
83
+ def file_lines
84
+ @file_lines ||= @file.read.split("\n")
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -13,7 +13,9 @@ require_relative "syncer/json_update_handler"
13
13
  require_relative "syncer/merger"
14
14
  require_relative "syncer/operation"
15
15
  require_relative "syncer/standard_reporter"
16
+ require_relative "syncer/unsupported_script_warning"
16
17
  require_relative "theme_admin_api"
18
+ require_relative "theme_admin_api_throttler"
17
19
 
18
20
  module ShopifyCLI
19
21
  module Theme
@@ -31,12 +33,12 @@ module ShopifyCLI
31
33
  :union_merge, # - Union merges the local file content with the remote file content
32
34
  ]
33
35
 
34
- attr_reader :theme, :checksums, :error_checksums
36
+ attr_reader :theme, :checksums, :error_checksums, :api_client
35
37
  attr_accessor :include_filter, :ignore_filter
36
38
 
37
39
  def_delegators :@error_reporter, :has_any_error?
38
40
 
39
- def initialize(ctx, theme:, include_filter: nil, ignore_filter: nil, overwrite_json: true)
41
+ def initialize(ctx, theme:, include_filter: nil, ignore_filter: nil, overwrite_json: true, stable: false)
40
42
  @ctx = ctx
41
43
  @theme = theme
42
44
  @include_filter = include_filter
@@ -58,15 +60,21 @@ module ShopifyCLI
58
60
  # Mutex used to pause all threads when backing-off when hitting API rate limits
59
61
  @backoff_mutex = Mutex.new
60
62
 
63
+ # Mutex used to coordinate changes in the `pending` list
64
+ @pending_mutex = Mutex.new
65
+
61
66
  # Latest theme assets checksums. Updated on each upload.
62
67
  @checksums = Checksums.new(theme)
63
68
 
64
69
  # Checksums of assets with errors.
65
70
  @error_checksums = []
66
- end
67
71
 
68
- def api_client
69
- @api_client ||= ThemeAdminAPI.new(@ctx, @theme.shop)
72
+ # Initialize `api_client` on main thread
73
+ @api_client = ThemeAdminAPIThrottler.new(
74
+ @ctx,
75
+ ThemeAdminAPI.new(@ctx, @theme.shop),
76
+ !stable
77
+ )
70
78
  end
71
79
 
72
80
  def lock_io!
@@ -134,6 +142,7 @@ module ShopifyCLI
134
142
  end
135
143
 
136
144
  def shutdown
145
+ api_client.shutdown
137
146
  @queue.close unless @queue.closed?
138
147
  ensure
139
148
  @threads.each { |thread| thread.join if thread.alive? }
@@ -185,6 +194,9 @@ module ShopifyCLI
185
194
  unless delay_low_priority_files
186
195
  wait!(&block)
187
196
  end
197
+
198
+ api_client.deactivate_throttler!
199
+ enqueue_delayed_files_updates
188
200
  end
189
201
 
190
202
  def download_theme!(delete: true, &block)
@@ -242,38 +254,57 @@ module ShopifyCLI
242
254
  wait_for_backoff!
243
255
  @ctx.debug(operation.to_s)
244
256
 
245
- response = send(operation.method, operation.file)
257
+ send(operation.method, operation.file) do |response|
258
+ raise response if response.is_a?(StandardError)
259
+
260
+ file = operation.file
246
261
 
247
- @standard_reporter.report(operation.as_synced_message)
262
+ if file.warnings.any?
263
+ warning_message =
264
+ operation.as_synced_message(color: :yellow) +
265
+ UnsupportedScriptWarning.new(@ctx, file).to_s
266
+ @error_reporter.report(warning_message)
267
+ else
268
+ @standard_reporter.report(operation.as_synced_message)
269
+ end
248
270
 
249
- # Check if the API told us we're near the rate limit
250
- if !backingoff? && (limit = response["x-shopify-shop-api-call-limit"])
251
- used, total = limit.split("/").map(&:to_i)
252
- backoff_if_near_limit!(used, total)
271
+ # Check if the API told us we're near the rate limit
272
+ if !backingoff? && (limit = response["x-shopify-shop-api-call-limit"])
273
+ used, total = limit.split("/").map(&:to_i)
274
+ backoff_if_near_limit!(used, total)
275
+ end
276
+ rescue StandardError => error
277
+ handle_operation_error(operation, error)
278
+ ensure
279
+ @pending_mutex.synchronize do
280
+ # Avoid abrupt jumps in the progress bar
281
+ wait(0.05)
282
+ @pending.delete(operation)
283
+ end
253
284
  end
254
- rescue ShopifyCLI::API::APIRequestError => e
255
- error_suffix = ":\n " + parse_api_errors(e).join("\n ")
256
- report_error(operation, error_suffix)
257
- ensure
258
- @pending.delete(operation)
285
+ rescue StandardError => error
286
+ handle_operation_error(operation, error)
259
287
  end
260
288
 
261
289
  def update(file)
262
290
  asset = { key: file.relative_path }
291
+
263
292
  if file.text?
264
293
  asset[:value] = file.read
265
294
  else
266
295
  asset[:attachment] = Base64.encode64(file.read)
267
296
  end
268
297
 
269
- _status, body, response = api_client.put(
270
- path: "themes/#{@theme.id}/assets.json",
271
- body: JSON.generate(asset: asset)
272
- )
298
+ path = "themes/#{@theme.id}/assets.json"
299
+ req_body = JSON.generate(asset: asset)
273
300
 
274
- update_checksums(body)
301
+ api_client.put(path: path, body: req_body) do |_status, resp_body, response|
302
+ update_checksums(resp_body)
303
+
304
+ file.warnings = resp_body.dig("asset", "warnings")
275
305
 
276
- response
306
+ yield(response) if block_given?
307
+ end
277
308
  end
278
309
 
279
310
  def get(file)
@@ -291,7 +322,7 @@ module ShopifyCLI
291
322
  file.write(body.dig("asset", "value"))
292
323
  end
293
324
 
294
- response
325
+ yield(response)
295
326
  end
296
327
 
297
328
  def delete(file)
@@ -302,7 +333,7 @@ module ShopifyCLI
302
333
  })
303
334
  )
304
335
 
305
- response
336
+ yield(response)
306
337
  end
307
338
 
308
339
  def union_merge(file)
@@ -311,11 +342,11 @@ module ShopifyCLI
311
342
  query: URI.encode_www_form("asset[key]" => file.relative_path),
312
343
  )
313
344
 
314
- return response unless file.text?
345
+ return yield(response) unless file.text?
315
346
 
316
347
  remote_content = body.dig("asset", "value")
317
348
 
318
- return response if remote_content.nil?
349
+ return yield(response) if remote_content.nil?
319
350
 
320
351
  content = Merger.union_merge(file, remote_content)
321
352
 
@@ -323,7 +354,7 @@ module ShopifyCLI
323
354
 
324
355
  enqueue(:update, file)
325
356
 
326
- response
357
+ yield(response)
327
358
  end
328
359
 
329
360
  def update_checksums(api_response)
@@ -336,7 +367,11 @@ module ShopifyCLI
336
367
  end
337
368
 
338
369
  def parse_api_errors(exception)
339
- parsed_body = JSON.parse(exception&.response&.body)
370
+ parsed_body = if exception&.response&.is_a?(Hash)
371
+ exception&.response&.[](:body)
372
+ else
373
+ JSON.parse(exception&.response&.body)
374
+ end
340
375
  message = parsed_body.dig("errors", "asset") || parsed_body["message"] || exception.message
341
376
  # Truncate to first lines
342
377
  [message].flatten.map { |m| m.split("\n", 2).first }
@@ -347,7 +382,7 @@ module ShopifyCLI
347
382
  def backoff_if_near_limit!(used, limit)
348
383
  if used > limit - @threads.size
349
384
  @ctx.debug("Near API call limit, waiting 2 sec…")
350
- @backoff_mutex.synchronize { sleep(2) }
385
+ @backoff_mutex.synchronize { wait(2) }
351
386
  end
352
387
  end
353
388
 
@@ -363,6 +398,15 @@ module ShopifyCLI
363
398
  # Sleeping in the mutex in another thread. Wait for unlock
364
399
  @backoff_mutex.synchronize {} if backingoff?
365
400
  end
401
+
402
+ def handle_operation_error(operation, error)
403
+ error_suffix = ":\n " + parse_api_errors(error).join("\n ")
404
+ report_error(operation, error_suffix)
405
+ end
406
+
407
+ def wait(duration)
408
+ sleep(duration)
409
+ end
366
410
  end
367
411
  end
368
412
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ShopifyCLI
2
4
  module Theme
3
5
  class ThemeAdminAPI
@@ -10,35 +12,36 @@ module ShopifyCLI
10
12
  @shop = shop || get_shop_or_abort
11
13
  end
12
14
 
13
- def get(path:, **args)
14
- rest_request(method: "GET", path: path, **args)
15
+ def get(path:, **args, &block)
16
+ rest_request(method: "GET", path: path, **args, &block)
15
17
  end
16
18
 
17
- def put(path:, **args)
18
- rest_request(method: "PUT", path: path, **args)
19
+ def put(path:, **args, &block)
20
+ rest_request(method: "PUT", path: path, **args, &block)
19
21
  end
20
22
 
21
- def post(path:, **args)
22
- rest_request(method: "POST", path: path, **args)
23
+ def post(path:, **args, &block)
24
+ rest_request(method: "POST", path: path, **args, &block)
23
25
  end
24
26
 
25
- def delete(path:, **args)
26
- rest_request(method: "DELETE", path: path, **args)
27
+ def delete(path:, **args, &block)
28
+ rest_request(method: "DELETE", path: path, **args, &block)
27
29
  end
28
30
 
29
31
  def get_shop_or_abort # rubocop:disable Naming/AccessorMethodName
30
32
  ShopifyCLI::AdminAPI.get_shop_or_abort(@ctx)
31
33
  end
32
34
 
33
- private
34
-
35
35
  def rest_request(**args)
36
- ShopifyCLI::AdminAPI.rest_request(
36
+ status, body, response = ShopifyCLI::AdminAPI.rest_request(
37
37
  @ctx,
38
38
  shop: @shop,
39
39
  api_version: API_VERSION,
40
40
  **args.compact
41
41
  )
42
+ return yield(status, body, response) if block_given?
43
+
44
+ [status, body, response]
42
45
  rescue ShopifyCLI::API::APIRequestForbiddenError,
43
46
  ShopifyCLI::API::APIRequestUnauthorizedError => error
44
47
 
@@ -62,6 +65,8 @@ module ShopifyCLI
62
65
  raise error
63
66
  end
64
67
 
68
+ private
69
+
65
70
  def permission_error
66
71
  ensure_user_error = @ctx.message("theme.ensure_user_error", shop)
67
72
  ensure_user_try_this = @ctx.message("theme.ensure_user_try_this")
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bulk_job"
4
+ require "shopify_cli/thread_pool"
5
+
6
+ module ShopifyCLI
7
+ module Theme
8
+ class ThemeAdminAPIThrottler
9
+ class Bulk
10
+ MAX_BULK_BYTESIZE = 10_485_760 # 10MB
11
+ MAX_BULK_FILES = 20 # files
12
+ QUEUE_TIMEOUT = 0.2 # 200ms
13
+
14
+ attr_accessor :admin_api
15
+
16
+ def initialize(ctx, admin_api, pool_size: 20)
17
+ @ctx = ctx
18
+ @admin_api = admin_api
19
+ @latest_enqueued_at = now
20
+
21
+ @thread_pool = ShopifyCLI::ThreadPool.new(pool_size: pool_size)
22
+
23
+ pool_size.times do
24
+ @thread_pool.schedule(
25
+ BulkJob.new(ctx, self)
26
+ )
27
+ end
28
+
29
+ @put_requests = []
30
+ @mut = Mutex.new
31
+ end
32
+
33
+ def enqueue(put_request)
34
+ @mut.synchronize do
35
+ @latest_enqueued_at = now
36
+ @put_requests << put_request
37
+ end
38
+ end
39
+
40
+ def shutdown
41
+ wait_put_requests
42
+ @thread_pool.shutdown
43
+ end
44
+
45
+ def consume_put_requests
46
+ to_batch = []
47
+ to_batch_size_bytes = 0
48
+ @mut.synchronize do
49
+ # sort requests to perform less retries at the `bulk_job` level
50
+ @put_requests.sort_by! { |r| r.liquid? ? 0 : 1 }
51
+
52
+ is_ready = false
53
+ until is_ready || @put_requests.empty?
54
+ request = @put_requests.first
55
+ if to_batch.empty? && request.size > MAX_BULK_BYTESIZE
56
+ is_ready = true
57
+ to_batch << request
58
+ to_batch_size_bytes += request.size
59
+ @put_requests.shift
60
+ elsif to_batch.size + 1 > MAX_BULK_FILES || to_batch_size_bytes + request.size > MAX_BULK_BYTESIZE
61
+ is_ready = true
62
+ else
63
+ to_batch << request
64
+ to_batch_size_bytes += request.size
65
+ @put_requests.shift
66
+ end
67
+ end
68
+ end
69
+ [to_batch, to_batch_size_bytes]
70
+ end
71
+
72
+ def ready?
73
+ queue_timeout? || bulk_size >= MAX_BULK_FILES || bulk_bytesize >= MAX_BULK_BYTESIZE
74
+ end
75
+
76
+ def bulk_bytesize
77
+ @put_requests.map(&:size).reduce(:+).to_i
78
+ end
79
+
80
+ private
81
+
82
+ def bulk_size
83
+ @put_requests.size
84
+ end
85
+
86
+ def queue_timeout?
87
+ return false if bulk_size.zero?
88
+ elapsed_time = now - @latest_enqueued_at
89
+ elapsed_time > QUEUE_TIMEOUT
90
+ end
91
+
92
+ def wait_put_requests
93
+ sleep(0.2) until @put_requests.empty?
94
+ end
95
+
96
+ def now
97
+ Time.now.to_f
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shopify_cli/thread_pool/job"
4
+ require_relative "request_parser"
5
+ require_relative "response_parser"
6
+ require_relative "errors"
7
+
8
+ module ShopifyCLI
9
+ module Theme
10
+ class ThemeAdminAPIThrottler
11
+ class BulkJob < ShopifyCLI::ThreadPool::Job
12
+ JOB_TIMEOUT = 0.2 # 200ms
13
+ MAX_RETRIES = 5
14
+
15
+ attr_reader :bulk
16
+
17
+ def initialize(ctx, bulk)
18
+ super(JOB_TIMEOUT)
19
+ @ctx = ctx
20
+ @bulk = bulk
21
+
22
+ # Mutex used to coordinate changes performed by the bulk item block
23
+ @block_mutex = Mutex.new
24
+ end
25
+
26
+ def perform!
27
+ return unless bulk.ready?
28
+ put_requests, bulk_size = bulk.consume_put_requests
29
+ return if put_requests.empty?
30
+
31
+ @ctx.debug("[BulkJob] size: #{put_requests.size}, bytesize: #{bulk_size}")
32
+ bulk_status, bulk_body, response = rest_request(put_requests)
33
+
34
+ if bulk_status == 207
35
+ responses(bulk_body).each_with_index do |tuple, index|
36
+ status, body = tuple
37
+ put_request = put_requests[index]
38
+ if status == 200 || put_request.retries >= MAX_RETRIES
39
+ @block_mutex.synchronize do
40
+ if status == 200
41
+ @ctx.debug("[BulkJob] asset saved: #{put_request.key}")
42
+ put_request.block.call(status, body, response)
43
+ else
44
+ @ctx.debug("[BulkJob] asset continuing with error: #{put_request.key}")
45
+ err = AssetUploadError.new(body, response: { body: body })
46
+ put_request.block.call(status, {}, err)
47
+ end
48
+ end
49
+ else
50
+ @ctx.debug("[BulkJob] asset error: #{put_request.key}")
51
+ @block_mutex.synchronize do
52
+ put_request.retries += 1
53
+ bulk.enqueue(put_request)
54
+ end
55
+ end
56
+ end
57
+ else
58
+ @ctx.puts(@ctx.message("theme.stable_flag_suggestion"))
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def rest_request(put_requests)
65
+ request = RequestParser.new(put_requests).parse
66
+ bulk.admin_api.rest_request(**request)
67
+ end
68
+
69
+ def responses(response_body)
70
+ ResponseParser.new(response_body).parse
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,7 @@
1
+ module ShopifyCLI
2
+ module Theme
3
+ class ThemeAdminAPIThrottler
4
+ class AssetUploadError < ShopifyCLI::API::APIRequestError; end
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shopify_cli/thread_pool/job"
4
+ require_relative "request_parser"
5
+ require_relative "response_parser"
6
+
7
+ module ShopifyCLI
8
+ module Theme
9
+ class ThemeAdminAPIThrottler
10
+ class PutRequest
11
+ attr_reader :method, :body, :path, :block
12
+ attr_accessor :retries
13
+
14
+ def initialize(path, body, &block)
15
+ @method = "PUT"
16
+ @path = path
17
+ @body = body
18
+ @block = block
19
+ @retries = 0
20
+ end
21
+
22
+ def to_h
23
+ {
24
+ method: method,
25
+ path: path,
26
+ body: body,
27
+ }
28
+ end
29
+
30
+ def to_s
31
+ "#{key}, retries: #{retries}"
32
+ end
33
+
34
+ def liquid?
35
+ key.end_with?(".liquid")
36
+ end
37
+
38
+ def key
39
+ @key ||= JSON.parse(body)["asset"]["key"]
40
+ end
41
+
42
+ def bulk_path
43
+ path.gsub(/.json$/, "/bulk.json")
44
+ end
45
+
46
+ def size
47
+ @size ||= body.bytesize
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyCLI
4
+ module Theme
5
+ class ThemeAdminAPIThrottler
6
+ class RequestParser
7
+ def initialize(requests)
8
+ @requests = requests
9
+ end
10
+
11
+ def parse
12
+ {
13
+ path: path,
14
+ method: method,
15
+ body: JSON.generate({ assets: assets }),
16
+ }
17
+ end
18
+
19
+ private
20
+
21
+ def method
22
+ @requests.sample.method
23
+ end
24
+
25
+ def path
26
+ @requests.sample.bulk_path
27
+ end
28
+
29
+ def assets
30
+ @requests.map do |request|
31
+ body = JSON.parse(request.body)
32
+ body = body.is_a?(Hash) ? body : JSON.parse(body)
33
+ body["asset"]
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyCLI
4
+ module Theme
5
+ class ThemeAdminAPIThrottler
6
+ class ResponseParser
7
+ def initialize(response_body)
8
+ @response_body = response_body
9
+ end
10
+
11
+ def parse
12
+ result = []
13
+ @response_body["results"]&.each do |resp|
14
+ result << [resp["code"], resp["body"]]
15
+ end
16
+ result
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end