shopify-cli 2.19.0 → 2.20.0

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