shopify-client 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +342 -0
  3. data/lib/shopify-client.rb +49 -0
  4. data/lib/shopify-client/authorise.rb +38 -0
  5. data/lib/shopify-client/bulk_request.rb +219 -0
  6. data/lib/shopify-client/cache/redis_store.rb +28 -0
  7. data/lib/shopify-client/cache/store.rb +67 -0
  8. data/lib/shopify-client/cache/thread_local_store.rb +47 -0
  9. data/lib/shopify-client/cached_request.rb +69 -0
  10. data/lib/shopify-client/client.rb +126 -0
  11. data/lib/shopify-client/client/logging.rb +38 -0
  12. data/lib/shopify-client/client/normalise_path.rb +14 -0
  13. data/lib/shopify-client/cookieless/check_header.rb +28 -0
  14. data/lib/shopify-client/cookieless/decode_session_token.rb +43 -0
  15. data/lib/shopify-client/cookieless/middleware.rb +39 -0
  16. data/lib/shopify-client/create_all_webhooks.rb +23 -0
  17. data/lib/shopify-client/create_webhook.rb +21 -0
  18. data/lib/shopify-client/delete_all_webhooks.rb +22 -0
  19. data/lib/shopify-client/delete_webhook.rb +13 -0
  20. data/lib/shopify-client/error.rb +9 -0
  21. data/lib/shopify-client/parse_link_header.rb +33 -0
  22. data/lib/shopify-client/request.rb +40 -0
  23. data/lib/shopify-client/resource/base.rb +46 -0
  24. data/lib/shopify-client/resource/create.rb +31 -0
  25. data/lib/shopify-client/resource/delete.rb +29 -0
  26. data/lib/shopify-client/resource/read.rb +80 -0
  27. data/lib/shopify-client/resource/update.rb +30 -0
  28. data/lib/shopify-client/response.rb +201 -0
  29. data/lib/shopify-client/response_errors.rb +59 -0
  30. data/lib/shopify-client/response_user_errors.rb +42 -0
  31. data/lib/shopify-client/struct.rb +10 -0
  32. data/lib/shopify-client/throttling/redis_strategy.rb +62 -0
  33. data/lib/shopify-client/throttling/strategy.rb +50 -0
  34. data/lib/shopify-client/throttling/thread_local_strategy.rb +29 -0
  35. data/lib/shopify-client/verify_request.rb +51 -0
  36. data/lib/shopify-client/verify_webhook.rb +24 -0
  37. data/lib/shopify-client/version.rb +5 -0
  38. data/lib/shopify-client/webhook.rb +32 -0
  39. data/lib/shopify-client/webhook_list.rb +50 -0
  40. metadata +206 -0
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ class ResponseErrors
5
+ class << self
6
+ # Certain error responses, such as 'Not Found', use a string rather than
7
+ # an object. For consistency, these messages are set under 'resource'.
8
+ #
9
+ # @param data [Hash] the complete response data
10
+ #
11
+ # @return [ResponseErrors]
12
+ def from_response_data(data)
13
+ errors = data['errors']
14
+
15
+ return new if errors.nil?
16
+
17
+ errors.is_a?(String) ? new('resource' => errors) : new(errors)
18
+ end
19
+ end
20
+
21
+ # @param errors [Hash]
22
+ def initialize(errors = {})
23
+ @errors = errors
24
+ end
25
+
26
+ # @return [Array<String]
27
+ def messages
28
+ @errors.map do |field, message|
29
+ "#{message} [#{field}]"
30
+ end
31
+ end
32
+
33
+ # message_patterns [Array<Regexp, String>]
34
+ #
35
+ # @return [Boolean]
36
+ def message?(message_patterns)
37
+ message_patterns.any? do |pattern|
38
+ case pattern
39
+ when Regexp
40
+ messages.any? { |message| message.match?(pattern) }
41
+ when String
42
+ messages.include?(pattern)
43
+ end
44
+ end
45
+ end
46
+
47
+ include Enumerable
48
+
49
+ # @see Hash#each
50
+ def each(...)
51
+ @errors.dup.each(...)
52
+ end
53
+
54
+ # @return [Hash]
55
+ def to_h
56
+ @errors.dup
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ class ResponseUserErrors < ResponseErrors
5
+ class << self
6
+ # @param data [Hash] the complete response data
7
+ #
8
+ # @return [ResponseErrors]
9
+ def from_response_data(data)
10
+ errors = find_user_errors(data) || {}
11
+
12
+ return new if errors.empty?
13
+
14
+ new(errors.to_h do |error|
15
+ [
16
+ error['field'] ? error['field'].join('.') : '.',
17
+ error['message'],
18
+ ]
19
+ end)
20
+ end
21
+
22
+ # Find user errors recursively.
23
+ #
24
+ # @param data [Hash]
25
+ #
26
+ # @return [Hash, nil]
27
+ def find_errors(data)
28
+ data.each do |key, value|
29
+ return value if key == 'userErrors'
30
+
31
+ if value.is_a?(Hash)
32
+ errors = find_errors(value)
33
+
34
+ return errors if errors
35
+ end
36
+ end
37
+
38
+ nil
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ class Struct < ::Struct
5
+ # @param pp [PrettyPrint]
6
+ def pretty_print(pp)
7
+ pp.text(inspect)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ module Throttling
5
+ # Use Redis to maintain API call limit throttling across threads/processes.
6
+ #
7
+ # No delay for requests up to half of the call limit.
8
+ class RedisStrategy < Strategy
9
+ # @see Strategy#interval
10
+ def interval(interval_key)
11
+ num_requests, max_requests, header_timestamp = Redis.current.hmget(interval_key,
12
+ :num_requests,
13
+ :max_requests,
14
+ :header_timestamp,
15
+ ).map(&:to_i)
16
+
17
+ num_requests = leak(num_requests, header_timestamp)
18
+
19
+ max_unthrottled_requests = max_requests / 2
20
+
21
+ if num_requests > num_unthrottled_requests
22
+ Rational((num_requests - num_unthrottled_requests) * leak_rate, 1000)
23
+ else
24
+ 0
25
+ end
26
+ end
27
+
28
+ # @see Strategy#after_sleep
29
+ def after_sleep(env, interval_key)
30
+ header = env[:response_headers]['X-Shopify-Shop-Api-Call-Limit']
31
+
32
+ return if header.nil?
33
+
34
+ num_requests, max_requests = header.split('/')
35
+
36
+ Redis.current.mapped_hmset(interval_key,
37
+ num_requests: num_requests,
38
+ max_requests: max_requests,
39
+ header_timestamp: header_timestamp,
40
+ )
41
+ end
42
+
43
+ # Find the actual number of requests by subtracting requests leaked by the
44
+ # leaky bucket algorithm since the last header timestamp.
45
+ #
46
+ # @param num_requests [Integer]
47
+ # @param header_timestamp [Integer]
48
+ #
49
+ # @return [Integer]
50
+ def leak(num_requests, header_timestamp)
51
+ n = Rational(timestamp - header_timestamp, leak_rate).floor
52
+
53
+ n > num_requests ? 0 : num_requests - n
54
+ end
55
+
56
+ # Leak rate of the leaky bucket algorithm in milliseconds.
57
+ def leak_rate
58
+ 500
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ module Throttling
5
+ # @abstract
6
+ class Strategy < Faraday::Middleware
7
+ # @param env [Faraday::Env]
8
+ def on_request(env)
9
+ interval_key = build_interval_key(env)
10
+
11
+ sleep(interval(interval_key))
12
+
13
+ after_sleep(env, interval_key)
14
+ end
15
+
16
+ # @param env [Faraday::Env]
17
+ #
18
+ # @return [String]
19
+ def build_interval_key(env)
20
+ myshopify_domain = env.dig(:shopify, :myshopify_domain) || 'unknown'
21
+
22
+ format('shopify-client:throttling:%s', myshopify_domain)
23
+ end
24
+
25
+ # Sleep interval in seconds.
26
+ #
27
+ # @param interval_key [String]
28
+ #
29
+ # @return [Numeric]
30
+ def interval(interval_key)
31
+ 0
32
+ end
33
+
34
+ # Hook for setting the interval key.
35
+ #
36
+ # @param env [Faraday::Env]
37
+ # @param interval_key [String]
38
+ def after_sleep(env, interval_key)
39
+ nil
40
+ end
41
+
42
+ # Time in milliseconds since the UNIX epoch.
43
+ #
44
+ # @return [Integer]
45
+ def timestamp
46
+ (Time.now.to_f * 1000).to_i
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ module Throttling
5
+ # Maintain API call limit throttling across a single thread.
6
+ class ThreadLocalStrategy < Strategy
7
+ # @see Strategy#interval
8
+ def interval(interval_key)
9
+ return 0 if Thread.current[interval_key].nil?
10
+
11
+ ms = (timestamp - Thread.current[interval_key])
12
+
13
+ ms < minimum_interval ? Rational(minimum_interval - ms, 1000) : 0
14
+ end
15
+
16
+ # @see Strategy#after_sleep
17
+ def after_sleep(env, interval_key)
18
+ Thread.current[interval_key] = timestamp
19
+ end
20
+
21
+ # Minimum time between requests in milliseconds.
22
+ #
23
+ # @return [Integer]
24
+ def minimum_interval
25
+ 500
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module ShopifyClient
6
+ class VerifyRequest
7
+ Error = Class.new(Error)
8
+
9
+ # Verify that the request originated from Shopify.
10
+ #
11
+ # @param params [Hash] the request params
12
+ #
13
+ # @raise [Error] if signature is invalid
14
+ def call(params)
15
+ params = params.to_h.transform_keys(&:to_s)
16
+ digest = OpenSSL::Digest::SHA256.new
17
+ digest = OpenSSL::HMAC.hexdigest(digest, ShopifyClient.config.shared_secret, encoded_params(params))
18
+
19
+ raise Error, 'invalid signature' unless digest == params['hmac']
20
+ end
21
+
22
+ # @param params [Hash]
23
+ #
24
+ # @return [String]
25
+ private def encoded_params(params)
26
+ params.reject do |k, _|
27
+ k == 'hmac'
28
+ end.map do |k, v|
29
+ [].tap do |param|
30
+ param << k.gsub(/./) { |c| encode_key(c) }
31
+ param << '='
32
+ param << v.gsub(/./) { |c| encode_val(c) }
33
+ end.join
34
+ end.join('&')
35
+ end
36
+
37
+ # @param chr [String]
38
+ #
39
+ # @return [String]
40
+ private def encode_key(chr)
41
+ {'%' => '%25', '&' => '%26', '=' => '%3D'}[chr] || chr
42
+ end
43
+
44
+ # @param chr [String]
45
+ #
46
+ # @return [String]
47
+ private def encode_val(chr)
48
+ {'%' => '%25', '&' => '%26'}[chr] || chr
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'openssl'
5
+
6
+ module ShopifyClient
7
+ class VerifyWebhook
8
+ Error = Class.new(Error)
9
+
10
+ # Verify that the webhook request originated from Shopify.
11
+ #
12
+ # @param data [String] the signed request data
13
+ # @param hmac [String] the signature
14
+ #
15
+ # @raise [Error] if signature is invalid
16
+ def call(data, hmac)
17
+ digest = OpenSSL::Digest::SHA256.new
18
+ digest = OpenSSL::HMAC.digest(digest, ShopifyClient.config.shared_secret, data)
19
+ digest = Base64.encode64(digest).strip
20
+
21
+ raise Error, 'invalid signature' unless digest == hmac
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ VERSION = '0.0.1'
5
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module ShopifyClient
6
+ # @!attribute [rw] myshopify_domain
7
+ # @return [String]
8
+ # @!attribute [rw] topic
9
+ # @return [String]
10
+ # @!attribute [rw] raw_data
11
+ # @return [String]
12
+ Webhook = Struct.new(:myshopify_domain, :topic, :raw_data) do
13
+ # @return [Hash]
14
+ def data
15
+ @data ||= JSON.parse(raw_data)
16
+ rescue JSON::ParserError
17
+ {}
18
+ end
19
+
20
+ alias_method :to_h, :data
21
+
22
+ # @return [String]
23
+ def to_json(*args)
24
+ to_h.to_json(*args)
25
+ end
26
+
27
+ # @return [String]
28
+ def inspect
29
+ "#<ShopifyClient::Webhook (#{myshopify_domain}, #{topic})>"
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ class WebhookList
5
+ def initialize
6
+ @webhooks = {}
7
+ end
8
+
9
+ # Register a handler for a webhook topic. The callable handler should
10
+ # receive a single {Webhook} argument.
11
+ #
12
+ # @param topic [String]
13
+ # @param handler [#call]
14
+ # @param fields [Array<String>] e.g. %w[id tags]
15
+ def register(topic, handler = nil, fields: nil, &block)
16
+ raise ArgumentError unless nil ^ handler ^ block
17
+
18
+ handler = block if block
19
+
20
+ self[topic][:handlers] << handler
21
+ self[topic][:fields] |= fields # merge fields with previous fields
22
+
23
+ nil
24
+ end
25
+
26
+ # Call each of the handlers registered for the given topic in turn.
27
+ #
28
+ # @param webhook [Webhook]
29
+ def delegate(webhook)
30
+ self[webhook.topic][:handlers].each do |handler|
31
+ handler.(webhook)
32
+ end
33
+ end
34
+
35
+ # @param topic [String]
36
+ def [](topic)
37
+ @webhooks[topic] ||= {
38
+ handlers: [],
39
+ fields: [],
40
+ }
41
+ end
42
+
43
+ include Enumerable
44
+
45
+ # @yield [Hash]
46
+ def each(&block)
47
+ @webhooks.each(&block)
48
+ end
49
+ end
50
+ end
metadata ADDED
@@ -0,0 +1,206 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: shopify-client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Kelsey Judson
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-07-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dotenv
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.7'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.7'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.10'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.10'
55
+ - !ruby/object:Gem::Dependency
56
+ name: addressable
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '2.7'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '2.7'
69
+ - !ruby/object:Gem::Dependency
70
+ name: dry-configurable
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.12'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.12'
83
+ - !ruby/object:Gem::Dependency
84
+ name: faraday
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.4'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.4'
97
+ - !ruby/object:Gem::Dependency
98
+ name: faraday_middleware
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: jwt
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '2.2'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '2.2'
125
+ - !ruby/object:Gem::Dependency
126
+ name: zeitwerk
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '2.4'
132
+ type: :runtime
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '2.4'
139
+ description:
140
+ email: kelsey@kelseyjudson.dev
141
+ executables: []
142
+ extensions: []
143
+ extra_rdoc_files: []
144
+ files:
145
+ - README.md
146
+ - lib/shopify-client.rb
147
+ - lib/shopify-client/authorise.rb
148
+ - lib/shopify-client/bulk_request.rb
149
+ - lib/shopify-client/cache/redis_store.rb
150
+ - lib/shopify-client/cache/store.rb
151
+ - lib/shopify-client/cache/thread_local_store.rb
152
+ - lib/shopify-client/cached_request.rb
153
+ - lib/shopify-client/client.rb
154
+ - lib/shopify-client/client/logging.rb
155
+ - lib/shopify-client/client/normalise_path.rb
156
+ - lib/shopify-client/cookieless/check_header.rb
157
+ - lib/shopify-client/cookieless/decode_session_token.rb
158
+ - lib/shopify-client/cookieless/middleware.rb
159
+ - lib/shopify-client/create_all_webhooks.rb
160
+ - lib/shopify-client/create_webhook.rb
161
+ - lib/shopify-client/delete_all_webhooks.rb
162
+ - lib/shopify-client/delete_webhook.rb
163
+ - lib/shopify-client/error.rb
164
+ - lib/shopify-client/parse_link_header.rb
165
+ - lib/shopify-client/request.rb
166
+ - lib/shopify-client/resource/base.rb
167
+ - lib/shopify-client/resource/create.rb
168
+ - lib/shopify-client/resource/delete.rb
169
+ - lib/shopify-client/resource/read.rb
170
+ - lib/shopify-client/resource/update.rb
171
+ - lib/shopify-client/response.rb
172
+ - lib/shopify-client/response_errors.rb
173
+ - lib/shopify-client/response_user_errors.rb
174
+ - lib/shopify-client/struct.rb
175
+ - lib/shopify-client/throttling/redis_strategy.rb
176
+ - lib/shopify-client/throttling/strategy.rb
177
+ - lib/shopify-client/throttling/thread_local_strategy.rb
178
+ - lib/shopify-client/verify_request.rb
179
+ - lib/shopify-client/verify_webhook.rb
180
+ - lib/shopify-client/version.rb
181
+ - lib/shopify-client/webhook.rb
182
+ - lib/shopify-client/webhook_list.rb
183
+ homepage: https://gitea.judson.nz/shopify-apps/shopify-client
184
+ licenses:
185
+ - ISC
186
+ metadata: {}
187
+ post_install_message:
188
+ rdoc_options: []
189
+ require_paths:
190
+ - lib
191
+ required_ruby_version: !ruby/object:Gem::Requirement
192
+ requirements:
193
+ - - ">="
194
+ - !ruby/object:Gem::Version
195
+ version: '0'
196
+ required_rubygems_version: !ruby/object:Gem::Requirement
197
+ requirements:
198
+ - - ">="
199
+ - !ruby/object:Gem::Version
200
+ version: '0'
201
+ requirements: []
202
+ rubygems_version: 3.2.15
203
+ signing_key:
204
+ specification_version: 4
205
+ summary: Shopify client library
206
+ test_files: []