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,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