lucid-shopify 0.34.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.
@@ -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