lucid-shopify 0.34.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lucid
4
+ module Shopify
5
+ module Types
6
+ include Dry.Types(default: :strict)
7
+
8
+ Logger = Instance(Logger)
9
+ end
10
+ end
11
+ 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lucid
4
+ module Shopify
5
+ VERSION = '0.34.0'
6
+ end
7
+ 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