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,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module ShopifyClient
6
+ module Cookieless
7
+ # Rack middleware implementing cookieless authentication with App Bridge
8
+ # session tokens.
9
+ #
10
+ # Returns a 401 response if a request is unauthorised.
11
+ class Middleware
12
+ # @param app [#call]
13
+ # @param is_authenticated [#call]
14
+ # predicate for deciding when the request should be checked
15
+ def initialize(app, is_authenticated: ->(env) { true })
16
+ @app = app
17
+
18
+ @is_authenticated = is_authenticated
19
+ end
20
+
21
+ # @param env [Hash]
22
+ #
23
+ # @param [Array<Integer, Hash{String => String}, Array<String>]
24
+ def call(env)
25
+ CheckHeader.new.(env) if @is_authenticated.(env)
26
+
27
+ @app.call(env)
28
+ rescue UnauthorisedError
29
+ Rack::Response.new do |response|
30
+ response.status = 401
31
+ response.set_header('Content-Type', 'application/json')
32
+ response.write({
33
+ error: 'Invalid session token',
34
+ }.to_json)
35
+ end.to_a
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ class CreateAllWebhooks
5
+ # Create all registered webhooks for a shop.
6
+ #
7
+ # @param client [Client]
8
+ #
9
+ # @return [Array<Hash>] response data
10
+ def call(client)
11
+ create_webhook = CreateWebhook.new
12
+
13
+ ShopifyClient.webhooks.map do |topic|
14
+ Thread.new do
15
+ create_webhook.(client, {
16
+ topic: topic,
17
+ fields: topic[:fields],
18
+ })
19
+ end
20
+ end.map(&:value)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ class CreateWebhook
5
+ # @param client [Client]
6
+ # @param webhook [Hash]
7
+ # @option webhook [String] :topic
8
+ # @option webhook [Array<String>] :fields
9
+ #
10
+ # @return [Hash] response data
11
+ def call(client, webhook)
12
+ client.post_json(credentials, 'webhooks', webhook: webhook.merge(
13
+ address: ShopifyClient.config.webhook_uri,
14
+ ))
15
+ rescue Response::Error => e
16
+ raise e unless e.response.errors.message?([
17
+ /has already been taken/,
18
+ ])
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ class DeleteAllWebhooks
5
+ # Delete any existing webhooks.
6
+ #
7
+ # @param client [Client]
8
+ #
9
+ # @return [Array<Hash>] response data
10
+ def call(client)
11
+ webhooks = client.get(credentials, 'webhooks')['webhooks']
12
+
13
+ delete_webhook = DeleteWebhook.new
14
+
15
+ webhooks.map do |webhook|
16
+ Thread.new do
17
+ delete_webhook.(client, webhook['id'])
18
+ end
19
+ end.map(&:value)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ class DeleteWebhook
5
+ # @param client [Client]
6
+ # @param id [Integer]
7
+ #
8
+ # @return [Hash] response data
9
+ def call(client, id)
10
+ client.delete(credentials, "webhooks/#{id}")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ # Subclass this class for all gem exceptions, so that callers may rescue
5
+ # any subclass with:
6
+ #
7
+ # rescue ShopifyClient::Error => e
8
+ Error = Class.new(StandardError)
9
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'addressable'
4
+
5
+ module ShopifyClient
6
+ class ParseLinkHeader
7
+ # Parse a Link header into query params for each pagination link (e.g.
8
+ # :next, :previous).
9
+ #
10
+ # @param link_header [String]
11
+ #
12
+ # @return [Hash]
13
+ def call(link_header)
14
+ link_header.split(',').map do |link|
15
+ url, rel = link.split(';') # rel should be the first param
16
+ url = url[/<(.*)>/, 1]
17
+ rel = rel[/rel="?(\w+)"?/, 1]
18
+
19
+ [
20
+ rel.to_sym,
21
+ params(url),
22
+ ]
23
+ end.to_h
24
+ end
25
+
26
+ # @param url [String]
27
+ #
28
+ # @return [Hash]
29
+ private def params(url)
30
+ Addressable::URI.parse(url).query_values
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ # @!attribute [rw] myshopify_domain
5
+ # @return [String]
6
+ # @!attribute [rw] access_token
7
+ # @return [String, nil]
8
+ # @!attribute [rw] method
9
+ # @return [Symbol]
10
+ # @!attribute [rw] path
11
+ # @return [String]
12
+ # @!attribute [rw] params
13
+ # @return [Hash]
14
+ # @!attribute [rw] headers
15
+ # @return [Hash]
16
+ # @!attribute [rw] data
17
+ # @return [Hash, nil]
18
+ # @!attribute [rw] client
19
+ # @return [Client, nil]
20
+ Request = Struct.new(
21
+ :myshopify_domain,
22
+ :access_token,
23
+ :method,
24
+ :path,
25
+ :params,
26
+ :headers,
27
+ :data,
28
+ :client,
29
+ ) do
30
+ # @return [Boolean]
31
+ def graphql?
32
+ path == '/graphql.json'
33
+ end
34
+
35
+ # @return [String]
36
+ def inspect
37
+ "#<ShopifyClient::Request (#{myshopify_domain}#{path})>"
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ module Resource
5
+ module Base
6
+ module ClassMethods
7
+ # Set the remote API resource name for the subclass. If a singular
8
+ # is not provided, the plural will be used, without any trailing 's'.
9
+ #
10
+ # @param resource_plural [String, #to_s]
11
+ # @param resource_singular [String, #to_s, nil]
12
+ #
13
+ # @example
14
+ # resource :orders
15
+ #
16
+ # @example
17
+ # resource :orders, :order
18
+ def resource(name_plural, name_singular = nil)
19
+ define_method(:resource_name) { name_plural.to_s }
20
+ define_method(:resource_name_singular) do
21
+ name_singular ? name_singular.to_s : name_plural.to_s.sub(/s$/, '')
22
+ end
23
+ end
24
+ end
25
+
26
+ # @param base [Class]
27
+ def self.included(base)
28
+ base.extend(ClassMethods)
29
+ end
30
+
31
+ # @abstract Use {ClassMethods#resource} to implement (required)
32
+ #
33
+ # @return [String]
34
+ def resource_name
35
+ raise NotImplementedError
36
+ end
37
+
38
+ # @abstract Use {ClassMethods#resource} to implement (required)
39
+ #
40
+ # @return [String]
41
+ def resource_name_singular
42
+ raise NotImplementedError
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ module Resource
5
+ module Create
6
+ # @param base [Class]
7
+ def self.included(base)
8
+ base.include(Base)
9
+ end
10
+
11
+ # @param client [Client]
12
+ # @param data [Hash]
13
+ #
14
+ # @return [Integer] the new result.id
15
+ def create(client, data)
16
+ result = client.post(resource_name, resource_name_singular => data).data[resource_name_singular]
17
+
18
+ result['id'].tap do |id|
19
+ ShopifyClient.config.logger({
20
+ source: 'shopify-client',
21
+ type: 'create',
22
+ info: {
23
+ resource: resource_name,
24
+ id: id,
25
+ },
26
+ }.to_json)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ module Resource
5
+ module Delete
6
+ # @param base [Class]
7
+ def self.included(base)
8
+ base.include(Base)
9
+ end
10
+
11
+ # @param client [Client]
12
+ # @param id [Integer]
13
+ def delete(client, id)
14
+ client.delete("#{resource_name}/#{id}")
15
+
16
+ ShopifyClient.config.logger({
17
+ source: 'shopify-client',
18
+ type: 'delete',
19
+ info: {
20
+ resource: resource_name,
21
+ id: id,
22
+ },
23
+ }.to_json)
24
+
25
+ nil
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ module Resource
5
+ module Read
6
+ module ClassMethods
7
+ # Set the default query params. Note that 'fields' may be passed as an
8
+ # array of strings.
9
+ #
10
+ # @param params [Hash]
11
+ #
12
+ # @example
13
+ # default_params fields: 'id,tags'
14
+ def default_params(params)
15
+ define_method(:default_params) { params }
16
+ end
17
+ end
18
+
19
+ # @param base [Class]
20
+ def self.included(base)
21
+ base.extend(ClassMethods)
22
+
23
+ base.include(Base)
24
+ end
25
+
26
+ # @abstract Use {ClassMethods#default_params} to implement (optional)
27
+ #
28
+ # @return [Hash]
29
+ def default_params
30
+ {}
31
+ end
32
+
33
+ # Find a single result.
34
+ #
35
+ # @param client [Client]
36
+ # @param id [Integer]
37
+ # @param params [Hash]
38
+ #
39
+ # @return [Hash]
40
+ def find_by_id(client, id, params = {})
41
+ params = default_params.merge(params)
42
+
43
+ client.get("#{resource_name}/#{id}", params).data[resource_name_singular]
44
+ end
45
+
46
+ # Find all results.
47
+ #
48
+ # @param client [Client]
49
+ # @param params [Hash]
50
+ #
51
+ # @return [Enumerator<Hash>]
52
+ #
53
+ # @raise [ArgumentError] if 'fields' does not include 'id'
54
+ def all(client, params = {})
55
+ raise ArgumentError, 'missing id field' unless has_id?(params)
56
+
57
+ Enumerator.new do |yielder|
58
+ response = client.get(resource_name, params)
59
+
60
+ loop do
61
+ response.data[resource_name].each { |result| yielder << result }
62
+
63
+ response = response.next_page || break
64
+ end
65
+ end
66
+ end
67
+
68
+ # @param params [Hash]
69
+ #
70
+ # @return [Boolean]
71
+ private def has_id?(params)
72
+ fields = params[:fields] || params['fields']
73
+
74
+ return true if fields.nil?
75
+
76
+ fields =~ /\bid\b/
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyClient
4
+ module Resource
5
+ module Update
6
+ # @param base [Class]
7
+ def self.included(base)
8
+ base.include(Base)
9
+ end
10
+
11
+ # @param client [Client]
12
+ # @param id [Integer]
13
+ # @param data [Hash]
14
+ def update(client, id, data)
15
+ client.put("#{resource_name}/#{id}", resource_name_singular => data)
16
+
17
+ ShopifyClient.config.logger({
18
+ source: 'shopify-client',
19
+ type: 'update',
20
+ info: {
21
+ resource: resource_name,
22
+ id: id,
23
+ },
24
+ }.to_json)
25
+
26
+ nil
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'addressable'
4
+
5
+ module ShopifyClient
6
+ # @!attribute [rw] request
7
+ # @return [Request]
8
+ # @!attribute [rw] status_code
9
+ # @return [Integer]
10
+ # @!attribute [rw] headers
11
+ # @return [Hash]
12
+ # @!attribute [rw] data
13
+ # @return [Hash]
14
+ Response = Struct.new(:request, :status_code, :headers, :data)
15
+
16
+ # NOTE: Reopened for proper scoping of error classes.
17
+ class Response
18
+ class << self
19
+ # @param faraday_response [Faraday::Response]
20
+ # @param client [Client]
21
+ #
22
+ # @return [Response]
23
+ def from_faraday_response(faraday_response, client = nil)
24
+ uri = Addressable::URI.parse(faraday_response.env[:url])
25
+
26
+ new(
27
+ Request.new(
28
+ # Merchant myshopify.domain.
29
+ uri.host,
30
+ # Merchant access token.
31
+ faraday_response.env[:request_headers]['X-Shopify-Access-Token'],
32
+ # Request HTTP method.
33
+ faraday_response.env[:method],
34
+ # Request path.
35
+ uri.path,
36
+ # Request params.
37
+ uri.query_values,
38
+ # Request headers.
39
+ faraday_response.env[:request_headers],
40
+ # Request data.
41
+ faraday_response.env[:request_body],
42
+ # Client used for the request.
43
+ client,
44
+ ),
45
+ faraday_response.status,
46
+ faraday_response.headers,
47
+ faraday_response.body || {},
48
+ ).tap(&:assert!)
49
+ end
50
+ end
51
+
52
+ # @raise [ClientError] for status 4xx
53
+ # @raise [ServerError] for status 5xx
54
+ #
55
+ # @see https://shopify.dev/concepts/about-apis/response-codes
56
+ def assert!
57
+ case status_code
58
+ when 401
59
+ if errors.message?([/access token/i])
60
+ raise InvalidAccessTokenError.new(request, self), 'Invalid access token'
61
+ else
62
+ raise ClientError.new(request, self)
63
+ end
64
+ when 402
65
+ raise ShopError.new(request, self), 'Shop is frozen, awaiting payment'
66
+ when 403
67
+ # NOTE: Not sure what this one means (undocumented).
68
+ if errors.message?([/unavailable shop/i])
69
+ raise ShopError.new(request, self), 'Shop is unavailable'
70
+ else
71
+ raise ClientError.new(request, self)
72
+ end
73
+ when 423
74
+ raise ShopError.new(request, self), 'Shop is locked'
75
+ when 400..499
76
+ raise ClientError.new(request, self)
77
+ when 500..599
78
+ raise ServerError.new(request, self)
79
+ end
80
+
81
+ # GraphQL always has status 200.
82
+ if request.graphql? && (errors? || user_errors?)
83
+ raise GraphQLClientError.new(request, self)
84
+ end
85
+ end
86
+
87
+ # @return [Hash]
88
+ private def link
89
+ @link ||= ParseLinkHeader.new.(headers['Link'] || '')
90
+ end
91
+
92
+ # Request the next page for a GET request, if any.
93
+ #
94
+ # @param [Client]
95
+ #
96
+ # @return [Response, nil]
97
+ def next_page(client = request.client)
98
+ raise ArgumentError, 'missing client' if client.nil?
99
+
100
+ return nil unless link[:next]
101
+
102
+ client.get(request.path, link[:next])
103
+ end
104
+
105
+ # Request the next page for a GET request, if any.
106
+ #
107
+ # @param [Client]
108
+ #
109
+ # @return [Response, nil]
110
+ def previous_page(client = request.client)
111
+ raise ArgumentError, 'missing client' if client.nil?
112
+
113
+ return nil unless link[:previous]
114
+
115
+ client.get(request.path, link[:previous])
116
+ end
117
+
118
+ # Response errors (usually included with a 422 response).
119
+ #
120
+ # @return [ResponseErrors]
121
+ def errors
122
+ @errors ||= ResponseErrors.from_response_data(data)
123
+ end
124
+
125
+ # @return [Boolean]
126
+ def errors?
127
+ errors.any?
128
+ end
129
+
130
+ # GraphQL user errors (errors in mutation input).
131
+ #
132
+ # @return [ResponseUserErrors, nil]
133
+ def user_errors
134
+ return nil unless request.graphql?
135
+
136
+ @user_errors ||= ResponseUserErrors.from_response_data(data)
137
+ end
138
+
139
+ # @return [Boolean]
140
+ def user_errors?
141
+ return false unless request.graphql?
142
+
143
+ user_errors.any?
144
+ end
145
+
146
+ # @return [String]
147
+ def inspect
148
+ "#<ShopifyClient::Response (#{status_code}, #{request.inspect})>"
149
+ end
150
+ end
151
+
152
+ class Response
153
+ # @!attribute [r] request
154
+ # @return [Request]
155
+ # @!attribute [r] response
156
+ # @return [Response]
157
+ class Error < Error
158
+ # @param request [Request]
159
+ # @param response [Response]
160
+ def initialize(request, response)
161
+ @request = request
162
+ @response = response
163
+ end
164
+
165
+ attr_reader :request
166
+ attr_reader :response
167
+
168
+ # @return [String]
169
+ def message
170
+ if response.errors?
171
+ "bad response (#{response.status_code}): #{response.errors.messages.first}"
172
+ else
173
+ "bad response (#{response.status_code})"
174
+ end
175
+ end
176
+ end
177
+
178
+ # Client errors in the 4xx range.
179
+ ClientError = Class.new(Error)
180
+ # Server errors in the 5xx range.
181
+ ServerError = Class.new(Error)
182
+ # The access token was not accepted.
183
+ InvalidAccessTokenError = Class.new(ClientError)
184
+ # The shop is frozen/locked/unavailable.
185
+ ShopError = Class.new(ClientError)
186
+
187
+ # The GraphQL API always responds with a status code of 200.
188
+ GraphQLClientError = Class.new(ClientError) do
189
+ def message
190
+ case
191
+ when response.errors?
192
+ "bad response: #{response.errors.messages.first}"
193
+ when response.user_errors?
194
+ "bad response: #{response.user_errors.messages.first}"
195
+ else
196
+ "bad response"
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end