shopify-client 0.0.1

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.
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: []