lucid-shopify 0.34.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +156 -0
- data/lib/lucid/shopify/activate_charge.rb +26 -0
- data/lib/lucid/shopify/authorise.rb +46 -0
- data/lib/lucid/shopify/client.rb +74 -0
- data/lib/lucid/shopify/config.rb +51 -0
- data/lib/lucid/shopify/container.rb +36 -0
- data/lib/lucid/shopify/create_all_webhooks.rb +27 -0
- data/lib/lucid/shopify/create_charge.rb +37 -0
- data/lib/lucid/shopify/create_webhook.rb +24 -0
- data/lib/lucid/shopify/credentials.rb +16 -0
- data/lib/lucid/shopify/delete_all_webhooks.rb +30 -0
- data/lib/lucid/shopify/delete_request.rb +17 -0
- data/lib/lucid/shopify/delete_webhook.rb +22 -0
- data/lib/lucid/shopify/error.rb +11 -0
- data/lib/lucid/shopify/get_request.rb +18 -0
- data/lib/lucid/shopify/post_request.rb +18 -0
- data/lib/lucid/shopify/put_request.rb +18 -0
- data/lib/lucid/shopify/redis_throttled_strategy.rb +75 -0
- data/lib/lucid/shopify/request.rb +55 -0
- data/lib/lucid/shopify/response.rb +127 -0
- data/lib/lucid/shopify/send_request.rb +101 -0
- data/lib/lucid/shopify/throttled_strategy.rb +51 -0
- data/lib/lucid/shopify/types.rb +11 -0
- data/lib/lucid/shopify/verify_callback.rb +55 -0
- data/lib/lucid/shopify/verify_webhook.rb +28 -0
- data/lib/lucid/shopify/version.rb +7 -0
- data/lib/lucid/shopify/webhook.rb +51 -0
- data/lib/lucid/shopify/webhook_handler_list.rb +39 -0
- data/lib/lucid/shopify/webhook_list.rb +29 -0
- data/lib/lucid/shopify.rb +71 -0
- data/lib/lucid-shopify.rb +3 -0
- metadata +199 -0
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'lucid/shopify'
|
4
|
+
|
5
|
+
if defined?(Redis)
|
6
|
+
module Lucid
|
7
|
+
module Shopify
|
8
|
+
# Use Redis to maintain API call limit throttling across threads/processes.
|
9
|
+
#
|
10
|
+
# No delay for requests up to half of the call limit.
|
11
|
+
class RedisThrottledStrategy < ThrottledStrategy
|
12
|
+
LEAK_RATE = 500
|
13
|
+
|
14
|
+
# @param redis_client [Redis]
|
15
|
+
def initialize(redis_client: Redis.current)
|
16
|
+
@redis_client = redis_client
|
17
|
+
end
|
18
|
+
|
19
|
+
# @param request [Request]
|
20
|
+
#
|
21
|
+
# @yieldreturn [Response]
|
22
|
+
#
|
23
|
+
# @return [Response]
|
24
|
+
def call(request, &send_request)
|
25
|
+
interval_key = build_interval_key(request)
|
26
|
+
|
27
|
+
interval(interval_key)
|
28
|
+
|
29
|
+
send_request.().tap do |res|
|
30
|
+
header = res.headers['X-Shopify-Shop-Api-Call-Limit']
|
31
|
+
|
32
|
+
next if header.nil?
|
33
|
+
|
34
|
+
cur, max = header.split('/')
|
35
|
+
|
36
|
+
@redis_client.mapped_hmset(interval_key,
|
37
|
+
cur: cur,
|
38
|
+
max: max,
|
39
|
+
at: timestamp
|
40
|
+
)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# If over half the call limit, sleep until requests leak back to the
|
45
|
+
# threshold.
|
46
|
+
#
|
47
|
+
# @param interval_key [String]
|
48
|
+
private def interval(interval_key)
|
49
|
+
cur, max, at = @redis_client.hmget(interval_key, :cur, :max, :at).map(&:to_i)
|
50
|
+
|
51
|
+
cur = leak(cur, at)
|
52
|
+
|
53
|
+
delay_threshold = max / 2 # no delay
|
54
|
+
|
55
|
+
if cur > delay_threshold
|
56
|
+
sleep(Rational((cur - delay_threshold) * LEAK_RATE, 1000))
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Find the actual value of {cur}, by subtracting requests leaked by the
|
61
|
+
# leaky bucket algorithm since the value was set.
|
62
|
+
#
|
63
|
+
# @param cur [Integer]
|
64
|
+
# @param at [Integer]
|
65
|
+
#
|
66
|
+
# @return [Integer]
|
67
|
+
private def leak(cur, at)
|
68
|
+
n = Rational(timestamp - at, LEAK_RATE).floor
|
69
|
+
|
70
|
+
n > cur ? 0 : cur - n
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'lucid/shopify'
|
4
|
+
|
5
|
+
module Lucid
|
6
|
+
module Shopify
|
7
|
+
# @abstract
|
8
|
+
class Request
|
9
|
+
extend Dry::Initializer
|
10
|
+
|
11
|
+
# @return [Credentials]
|
12
|
+
param :credentials
|
13
|
+
# @return [Symbol]
|
14
|
+
param :http_method
|
15
|
+
# @return [String] the endpoint relative to the base URL
|
16
|
+
param :path, reader: :private
|
17
|
+
# @return [Hash]
|
18
|
+
param :options, default: -> { {} }
|
19
|
+
|
20
|
+
# @return [Hash]
|
21
|
+
param :http_headers, default: -> { build_headers }
|
22
|
+
# @return [String]
|
23
|
+
param :url, default: -> { build_url }
|
24
|
+
|
25
|
+
# @return [String]
|
26
|
+
private def build_url
|
27
|
+
unless path.match?(/oauth/)
|
28
|
+
admin_url = "https://#{credentials.myshopify_domain}/admin/api/#{api_version}"
|
29
|
+
else
|
30
|
+
admin_url = "https://#{credentials.myshopify_domain}/admin"
|
31
|
+
end
|
32
|
+
|
33
|
+
normalised_path = path.sub(/^\//, '')
|
34
|
+
normalised_path = path.sub(/\.json$/, '')
|
35
|
+
|
36
|
+
admin_url + '/' + normalised_path + '.json'
|
37
|
+
end
|
38
|
+
|
39
|
+
# @return [Hash]
|
40
|
+
private def build_headers
|
41
|
+
access_token = credentials.access_token
|
42
|
+
|
43
|
+
{}.tap do |headers|
|
44
|
+
headers['Accept'] = 'application/json'
|
45
|
+
headers['X-Shopify-Access-Token'] = access_token if access_token
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# @return [String]
|
50
|
+
private def api_version
|
51
|
+
ENV.fetch('SHOPIFY_API_VERSION', Shopify.config.api_version)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
require 'lucid/shopify'
|
6
|
+
|
7
|
+
module Lucid
|
8
|
+
module Shopify
|
9
|
+
class Response
|
10
|
+
# @abstract
|
11
|
+
class Error < Error
|
12
|
+
extend Dry::Initializer
|
13
|
+
|
14
|
+
# @return [Request]
|
15
|
+
param :request
|
16
|
+
# @return [Response]
|
17
|
+
param :response
|
18
|
+
|
19
|
+
# @return [String]
|
20
|
+
def message
|
21
|
+
"bad response (#{response.status_code})"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
ClientError = Class.new(Error)
|
26
|
+
ServerError = Class.new(Error)
|
27
|
+
ShopError = Class.new(Error)
|
28
|
+
|
29
|
+
extend Dry::Initializer
|
30
|
+
|
31
|
+
include Enumerable
|
32
|
+
|
33
|
+
# @return [Request] the original request
|
34
|
+
param :request
|
35
|
+
# @return [Integer]
|
36
|
+
param :status_code
|
37
|
+
# @return [Hash]
|
38
|
+
param :headers
|
39
|
+
# @return [String]
|
40
|
+
param :data
|
41
|
+
|
42
|
+
# The parsed response body.
|
43
|
+
#
|
44
|
+
# @return [Hash]
|
45
|
+
def data_hash
|
46
|
+
return {} unless json?
|
47
|
+
|
48
|
+
@data_hash ||= JSON.parse(data)
|
49
|
+
end
|
50
|
+
|
51
|
+
# @return [Boolean]
|
52
|
+
private def json?
|
53
|
+
headers['Content-Type'] =~ /application\/json/ && !data.empty?
|
54
|
+
end
|
55
|
+
|
56
|
+
# @return [self]
|
57
|
+
#
|
58
|
+
# @raise [ClientError] for status 4xx
|
59
|
+
# @raise [ServerError] for status 5xx
|
60
|
+
#
|
61
|
+
# @note https://help.shopify.com/en/api/getting-started/response-status-codes
|
62
|
+
def assert!
|
63
|
+
case status_code
|
64
|
+
when 402
|
65
|
+
raise ShopError.new(request, self), 'Shop is frozen, awaiting payment'
|
66
|
+
when 423
|
67
|
+
raise ShopError.new(request, self), 'Shop is locked'
|
68
|
+
when 400..499
|
69
|
+
raise ClientError.new(request, self)
|
70
|
+
when 500..599
|
71
|
+
raise ServerError.new(request, self)
|
72
|
+
end
|
73
|
+
|
74
|
+
self
|
75
|
+
end
|
76
|
+
|
77
|
+
# @return [Boolean]
|
78
|
+
def success?
|
79
|
+
status_code.between?(200, 299)
|
80
|
+
end
|
81
|
+
|
82
|
+
# @return [Boolean]
|
83
|
+
def failure?
|
84
|
+
!success?
|
85
|
+
end
|
86
|
+
|
87
|
+
# @return [Boolean]
|
88
|
+
def errors?
|
89
|
+
data_hash.has_key?('errors')
|
90
|
+
end
|
91
|
+
|
92
|
+
# A string rather than an object is returned by Shopify in the case of,
|
93
|
+
# e.g., 'Not found'. In this case, we return it under the 'resource' key.
|
94
|
+
#
|
95
|
+
# @return [Hash, nil]
|
96
|
+
def errors
|
97
|
+
errors = data_hash['errors']
|
98
|
+
return {'resource' => errors} if errors.is_a?(String)
|
99
|
+
errors
|
100
|
+
end
|
101
|
+
|
102
|
+
# @see Hash#each
|
103
|
+
def each(&block)
|
104
|
+
data_hash.each(&block)
|
105
|
+
end
|
106
|
+
|
107
|
+
# @param key [String]
|
108
|
+
#
|
109
|
+
# @return [Object]
|
110
|
+
def [](key)
|
111
|
+
data_hash[key]
|
112
|
+
end
|
113
|
+
|
114
|
+
alias_method :to_h, :data_hash
|
115
|
+
|
116
|
+
# @return [Hash]
|
117
|
+
def as_json(*)
|
118
|
+
to_h
|
119
|
+
end
|
120
|
+
|
121
|
+
# @return [String]
|
122
|
+
def to_json(*args)
|
123
|
+
as_json.to_json(*args)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'lucid/shopify'
|
4
|
+
|
5
|
+
module Lucid
|
6
|
+
module Shopify
|
7
|
+
class SendRequest
|
8
|
+
class NetworkError < Error
|
9
|
+
extend Dry::Initializer
|
10
|
+
|
11
|
+
# @return [HTTP::Error]
|
12
|
+
param :original_exception
|
13
|
+
end
|
14
|
+
|
15
|
+
# @param http [HTTP::Client]
|
16
|
+
# @param strategy [#call, nil] unthrottled by default
|
17
|
+
def initialize(http: Container[:http],
|
18
|
+
strategy: ->(*, &block) { block.() })
|
19
|
+
@http = http
|
20
|
+
@strategy = strategy
|
21
|
+
end
|
22
|
+
|
23
|
+
# @param request [Request]
|
24
|
+
# @param attempts [Integer] additional request attempts on client error
|
25
|
+
#
|
26
|
+
# @return [Hash] the parsed response body
|
27
|
+
#
|
28
|
+
# @raise [NetworkError] if the request failed all attempts
|
29
|
+
# @raise [Response::ClientError] for status 4xx
|
30
|
+
# @raise [Response::ServerError] for status 5xx
|
31
|
+
def call(request, attempts: default_attempts)
|
32
|
+
req = request
|
33
|
+
|
34
|
+
log_request(req)
|
35
|
+
|
36
|
+
res = @strategy.(req) do
|
37
|
+
res = send(req)
|
38
|
+
|
39
|
+
Response.new(req, res.code, res.headers.to_h, res.to_s)
|
40
|
+
end
|
41
|
+
|
42
|
+
log_response(req, res)
|
43
|
+
|
44
|
+
res.assert!
|
45
|
+
rescue HTTP::ConnectionError,
|
46
|
+
HTTP::ResponseError,
|
47
|
+
HTTP::TimeoutError => e
|
48
|
+
raise NetworkError.new(e), e.message if attempts.zero?
|
49
|
+
|
50
|
+
call(req, attempts: attempts - 1)
|
51
|
+
rescue Response::ClientError => e
|
52
|
+
raise e unless e.response.status_code == 429
|
53
|
+
|
54
|
+
sleep(e.response.headers['Retry-After']&.to_f || 0)
|
55
|
+
|
56
|
+
call(req, attempts: attempts)
|
57
|
+
end
|
58
|
+
|
59
|
+
# @param request [Request]
|
60
|
+
#
|
61
|
+
# @return [HTTP::Response]
|
62
|
+
private def send(request)
|
63
|
+
req = request
|
64
|
+
|
65
|
+
@http.headers(req.http_headers).__send__(req.http_method, req.url, req.options)
|
66
|
+
end
|
67
|
+
|
68
|
+
# @param request [Request]
|
69
|
+
def log_request(request)
|
70
|
+
req = request
|
71
|
+
|
72
|
+
Shopify.config.logger.info('<%s> [%i] %s %s %s' % [
|
73
|
+
self.class.to_s,
|
74
|
+
req.object_id,
|
75
|
+
req.http_method.to_s.upcase,
|
76
|
+
req.url,
|
77
|
+
req.options[:params]&.to_json || '{}',
|
78
|
+
])
|
79
|
+
end
|
80
|
+
|
81
|
+
# @param request [Request]
|
82
|
+
# @param response [Response]
|
83
|
+
def log_response(request, response)
|
84
|
+
req = request
|
85
|
+
res = response
|
86
|
+
|
87
|
+
Shopify.config.logger.info('<%s> [%i] %i (%s)' % [
|
88
|
+
self.class.to_s,
|
89
|
+
req.object_id,
|
90
|
+
res.status_code,
|
91
|
+
res.headers['X-Shopify-Shop-Api-Call-Limit'],
|
92
|
+
])
|
93
|
+
end
|
94
|
+
|
95
|
+
# @return [Integer]
|
96
|
+
private def default_attempts
|
97
|
+
3
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'lucid/shopify'
|
4
|
+
|
5
|
+
module Lucid
|
6
|
+
module Shopify
|
7
|
+
# Maintain API call limit throttling across a single thread.
|
8
|
+
class ThrottledStrategy
|
9
|
+
MINIMUM_INTERVAL = 500 # ms
|
10
|
+
|
11
|
+
# @param request [Request]
|
12
|
+
#
|
13
|
+
# @yieldreturn [Response]
|
14
|
+
#
|
15
|
+
# @return [Response]
|
16
|
+
def call(request, &send_request)
|
17
|
+
interval(build_interval_key(request))
|
18
|
+
|
19
|
+
send_request.()
|
20
|
+
end
|
21
|
+
|
22
|
+
# If time since the last request < {MINIMUM_INTERVAL}, then sleep for the
|
23
|
+
# difference.
|
24
|
+
#
|
25
|
+
# @param interval_key [String]
|
26
|
+
private def interval(interval_key)
|
27
|
+
if Thread.current[interval_key]
|
28
|
+
(timestamp - Thread.current[interval_key]).tap do |n|
|
29
|
+
sleep(Rational(MINIMUM_INTERVAL - n, 1000)) if n < MINIMUM_INTERVAL
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
Thread.current[interval_key] = timestamp
|
34
|
+
end
|
35
|
+
|
36
|
+
# @param request [Request]
|
37
|
+
#
|
38
|
+
# @return [String]
|
39
|
+
private def build_interval_key(request)
|
40
|
+
'%s[%s].timestamp' % [self.class, request.credentials.myshopify_domain]
|
41
|
+
end
|
42
|
+
|
43
|
+
# Time in milliseconds since the UNIX epoch.
|
44
|
+
#
|
45
|
+
# @return [Integer]
|
46
|
+
private def timestamp
|
47
|
+
(Time.now.to_f * 1000).to_i
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'openssl'
|
4
|
+
|
5
|
+
require 'lucid/shopify'
|
6
|
+
|
7
|
+
module Lucid
|
8
|
+
module Shopify
|
9
|
+
class VerifyCallback
|
10
|
+
Error = Class.new(Error)
|
11
|
+
|
12
|
+
# Verify that the callback request originated from Shopify.
|
13
|
+
#
|
14
|
+
# @param params [Hash] the request params
|
15
|
+
#
|
16
|
+
# @raise [Error] if signature is invalid
|
17
|
+
def call(params)
|
18
|
+
params = params.to_h.transform_keys(&:to_s)
|
19
|
+
digest = OpenSSL::Digest::SHA256.new
|
20
|
+
digest = OpenSSL::HMAC.hexdigest(digest, Shopify.config.shared_secret, encoded_params(params))
|
21
|
+
|
22
|
+
raise Error, 'invalid signature' unless digest == params['hmac']
|
23
|
+
end
|
24
|
+
|
25
|
+
# @param params [Hash]
|
26
|
+
#
|
27
|
+
# @return [String]
|
28
|
+
private def encoded_params(params)
|
29
|
+
params.reject do |k, _|
|
30
|
+
k == 'hmac'
|
31
|
+
end.map do |k, v|
|
32
|
+
[].tap do |param|
|
33
|
+
param << k.gsub(/./) { |c| encode_key(c) }
|
34
|
+
param << '='
|
35
|
+
param << v.gsub(/./) { |c| encode_val(c) }
|
36
|
+
end.join
|
37
|
+
end.join('&')
|
38
|
+
end
|
39
|
+
|
40
|
+
# @param chr [String]
|
41
|
+
#
|
42
|
+
# @return [String]
|
43
|
+
private def encode_key(chr)
|
44
|
+
{'%' => '%25', '&' => '%26', '=' => '%3D'}[chr] || chr
|
45
|
+
end
|
46
|
+
|
47
|
+
# @param chr [String]
|
48
|
+
#
|
49
|
+
# @return [String]
|
50
|
+
private def encode_val(chr)
|
51
|
+
{'%' => '%25', '&' => '%26'}[chr] || chr
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'base64'
|
4
|
+
require 'openssl'
|
5
|
+
|
6
|
+
require 'lucid/shopify'
|
7
|
+
|
8
|
+
module Lucid
|
9
|
+
module Shopify
|
10
|
+
class VerifyWebhook
|
11
|
+
Error = Class.new(Error)
|
12
|
+
|
13
|
+
# Verify that the webhook request originated from Shopify.
|
14
|
+
#
|
15
|
+
# @param data [String] the signed request data
|
16
|
+
# @param hmac [String] the signature
|
17
|
+
#
|
18
|
+
# @raise [Error] if signature is invalid
|
19
|
+
def call(data, hmac)
|
20
|
+
digest = OpenSSL::Digest::SHA256.new
|
21
|
+
digest = OpenSSL::HMAC.digest(digest, Shopify.config.shared_secret, data)
|
22
|
+
digest = Base64.encode64(digest).strip
|
23
|
+
|
24
|
+
raise Error, 'invalid signature' unless digest == hmac
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
require 'lucid/shopify'
|
6
|
+
|
7
|
+
module Lucid
|
8
|
+
module Shopify
|
9
|
+
class Webhook
|
10
|
+
extend Dry::Initializer
|
11
|
+
|
12
|
+
# @return [String]
|
13
|
+
param :myshopify_domain
|
14
|
+
# @return [String]
|
15
|
+
param :topic
|
16
|
+
# @return [String]
|
17
|
+
param :data
|
18
|
+
|
19
|
+
# @return [Hash]
|
20
|
+
def data_hash
|
21
|
+
@data_hash ||= JSON.parse(data)
|
22
|
+
rescue JSON::ParserError
|
23
|
+
{}
|
24
|
+
end
|
25
|
+
|
26
|
+
# @see Hash#each
|
27
|
+
def each(&block)
|
28
|
+
data_hash.each(&block)
|
29
|
+
end
|
30
|
+
|
31
|
+
# @param key [String]
|
32
|
+
#
|
33
|
+
# @return [Object]
|
34
|
+
def [](key)
|
35
|
+
data_hash[key]
|
36
|
+
end
|
37
|
+
|
38
|
+
alias_method :to_h, :data_hash
|
39
|
+
|
40
|
+
# @return [Hash]
|
41
|
+
def as_json(*)
|
42
|
+
to_h
|
43
|
+
end
|
44
|
+
|
45
|
+
# @return [String]
|
46
|
+
def to_json(*args)
|
47
|
+
as_json.to_json(*args)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Lucid
|
4
|
+
module Shopify
|
5
|
+
class WebhookHandlerList
|
6
|
+
def initialize
|
7
|
+
@handlers = {}
|
8
|
+
end
|
9
|
+
|
10
|
+
# Register a handler for a webhook topic. The callable handler should
|
11
|
+
# receive a single {Webhook} argument.
|
12
|
+
#
|
13
|
+
# @param topic [String]
|
14
|
+
# @param handler [#call]
|
15
|
+
def register(topic, handler = nil, &block)
|
16
|
+
raise ArgumentError unless nil ^ handler ^ block
|
17
|
+
|
18
|
+
handler = block if block
|
19
|
+
|
20
|
+
@handlers[topic] ||= []
|
21
|
+
@handlers[topic] << handler
|
22
|
+
|
23
|
+
nil
|
24
|
+
end
|
25
|
+
|
26
|
+
# @param topic [String]
|
27
|
+
def [](topic)
|
28
|
+
@handlers[topic] || []
|
29
|
+
end
|
30
|
+
|
31
|
+
# Call each of the handlers registered for the given topic in turn.
|
32
|
+
#
|
33
|
+
# @param webhook [Webhook]
|
34
|
+
def delegate(webhook)
|
35
|
+
self[webhook.topic].each { |handler| handler.(webhook) }
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Lucid
|
4
|
+
module Shopify
|
5
|
+
class WebhookList
|
6
|
+
include Enumerable
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@webhooks = []
|
10
|
+
end
|
11
|
+
|
12
|
+
# @yield [Hash]
|
13
|
+
def each(&block)
|
14
|
+
@webhooks.each(&block)
|
15
|
+
end
|
16
|
+
|
17
|
+
# @param topic [String]
|
18
|
+
# @param fields [String] e.g. 'id,tags'
|
19
|
+
def register(topic, fields: nil)
|
20
|
+
@webhooks << {}.tap do |webhook|
|
21
|
+
webhook[:topic] = topic
|
22
|
+
webhook[:fields] = fields if fields
|
23
|
+
end
|
24
|
+
|
25
|
+
nil
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|