shopify_graphql 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +20 -0
- data/README.md +262 -0
- data/app/controllers/shopify_graphql/graphql_webhooks_controller.rb +35 -0
- data/config/routes.rb +5 -0
- data/lib/shopify_graphql/client.rb +105 -0
- data/lib/shopify_graphql/configuration.rb +29 -0
- data/lib/shopify_graphql/controller_concerns/payload_verification.rb +23 -0
- data/lib/shopify_graphql/controller_concerns/webhook_verification.rb +22 -0
- data/lib/shopify_graphql/engine.rb +32 -0
- data/lib/shopify_graphql/exceptions.rb +74 -0
- data/lib/shopify_graphql/jobs/create_webhooks_job.rb +19 -0
- data/lib/shopify_graphql/jobs/destroy_webhooks_job.rb +19 -0
- data/lib/shopify_graphql/jobs/update_webhooks_job.rb +19 -0
- data/lib/shopify_graphql/managers/webhooks_manager.rb +85 -0
- data/lib/shopify_graphql/mutation.rb +15 -0
- data/lib/shopify_graphql/query.rb +15 -0
- data/lib/shopify_graphql/resource.rb +13 -0
- data/lib/shopify_graphql/resources/shop.rb +21 -0
- data/lib/shopify_graphql/resources/webhook.rb +77 -0
- data/lib/shopify_graphql/response.rb +29 -0
- data/lib/shopify_graphql/version.rb +3 -0
- data/lib/shopify_graphql.rb +28 -0
- data/lib/tasks/shopify_graphql_tasks.rake +4 -0
- metadata +129 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: c5d57d24db5937afd5ca663c1302d3329df42217689189a6fdd352e7a2dc9a8c
|
4
|
+
data.tar.gz: 9800960c97b1f454fd4c7f820dee12290ded69a897492e5f573ef86e7d33b564
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: bfbc348ebd97d9945a9738b23eb1968d1fa76101e320255681229c6800cefbc9341c1d3ae62aed64a3fa3442fd602072d40a6ac77e76e2f5c051f7e37cef1aec
|
7
|
+
data.tar.gz: d487e89b766310f3f0496ec7de788efa0c215a69199153eb9b42c732774e0166f298e7e4763c7bd25ad63972b58810cf2d091a4d92cef44b1820d6fdf9d2b8cc
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2021 Kirill Platonov
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,262 @@
|
|
1
|
+
# Shopify Graphql
|
2
|
+
|
3
|
+
Less painful way to work with [Shopify Graphql API](https://shopify.dev/api/admin/graphql/reference) in Ruby.
|
4
|
+
|
5
|
+
> **This library is under active development. Breaking changes are likely until stable release.**
|
6
|
+
|
7
|
+
## Features
|
8
|
+
|
9
|
+
- Simple API for Graphql calls
|
10
|
+
- Graphql webhooks integration
|
11
|
+
- Built-in error handling
|
12
|
+
- No schema and no memory issues
|
13
|
+
- (Planned) Testing helpers
|
14
|
+
- (Planned) Buil-in retry on error
|
15
|
+
- (Planned) Pre-built calls for common Graphql operations
|
16
|
+
|
17
|
+
## Usage
|
18
|
+
|
19
|
+
### Making Graphql calls directly
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
CREATE_WEBHOOK_MUTATION = <<~GRAPHQL
|
23
|
+
mutation($topic: WebhookSubscriptionTopic!, $webhookSubscription: WebhookSubscriptionInput!) {
|
24
|
+
webhookSubscriptionCreate(topic: $topic, webhookSubscription: $webhookSubscription) {
|
25
|
+
webhookSubscription {
|
26
|
+
id
|
27
|
+
}
|
28
|
+
userErrors {
|
29
|
+
field
|
30
|
+
message
|
31
|
+
}
|
32
|
+
}
|
33
|
+
}
|
34
|
+
GRAPHQL
|
35
|
+
|
36
|
+
response = ShopifyGraphql.execute(CREATE_WEBHOOK_MUTATION,
|
37
|
+
topic: "TOPIC",
|
38
|
+
webhookSubscription: { callbackUrl: "ADDRESS", format: "JSON" },
|
39
|
+
)
|
40
|
+
response = response.data.webhookSubscriptionCreate
|
41
|
+
ShopifyGraphql.handle_user_errors(response)
|
42
|
+
```
|
43
|
+
|
44
|
+
### Creating wrappers for queries, mutations, and fields
|
45
|
+
|
46
|
+
To isolate Graphql boilerplate you can create wrappers. To keep them organized use the following conventions:
|
47
|
+
- Put them all into `app/graphql` folder
|
48
|
+
- Use `Fields` suffix to name fields (eg `AppSubscriptionFields`)
|
49
|
+
- Use `Get` prefix to name queries (eg `GetProducts` or `GetAppSubscription`)
|
50
|
+
- Use imperative to name mutations (eg `CreateUsageSubscription` or `BulkUpdateVariants`)
|
51
|
+
|
52
|
+
#### Example fields
|
53
|
+
|
54
|
+
Definition:
|
55
|
+
```ruby
|
56
|
+
class AppSubscriptionFields
|
57
|
+
FRAGMENT = <<~GRAPHQL
|
58
|
+
fragment AppSubscriptionFields on AppSubscription {
|
59
|
+
id
|
60
|
+
name
|
61
|
+
status
|
62
|
+
trialDays
|
63
|
+
currentPeriodEnd
|
64
|
+
test
|
65
|
+
lineItems {
|
66
|
+
id
|
67
|
+
plan {
|
68
|
+
pricingDetails {
|
69
|
+
__typename
|
70
|
+
... on AppRecurringPricing {
|
71
|
+
price {
|
72
|
+
amount
|
73
|
+
}
|
74
|
+
interval
|
75
|
+
}
|
76
|
+
... on AppUsagePricing {
|
77
|
+
balanceUsed {
|
78
|
+
amount
|
79
|
+
}
|
80
|
+
cappedAmount {
|
81
|
+
amount
|
82
|
+
}
|
83
|
+
interval
|
84
|
+
terms
|
85
|
+
}
|
86
|
+
}
|
87
|
+
}
|
88
|
+
}
|
89
|
+
}
|
90
|
+
GRAPHQL
|
91
|
+
|
92
|
+
def self.parse(data)
|
93
|
+
recurring_line_item = data.lineItems.find { |i| i.plan.pricingDetails.__typename == "AppRecurringPricing" }
|
94
|
+
recurring_pricing = recurring_line_item&.plan&.pricingDetails
|
95
|
+
usage_line_item = data.lineItems.find { |i| i.plan.pricingDetails.__typename == "AppUsagePricing" }
|
96
|
+
usage_pricing = usage_line_item&.plan&.pricingDetails
|
97
|
+
|
98
|
+
OpenStruct.new(
|
99
|
+
id: data.id,
|
100
|
+
name: data.name,
|
101
|
+
status: data.status,
|
102
|
+
trial_days: data.trialDays,
|
103
|
+
current_period_end: data.currentPeriodEnd && Time.parse(data.currentPeriodEnd),
|
104
|
+
test: data.test,
|
105
|
+
recurring_line_item_id: recurring_line_item&.id,
|
106
|
+
recurring_price: recurring_pricing&.price&.amount&.to_d,
|
107
|
+
recurring_interval: recurring_pricing&.interval,
|
108
|
+
usage_line_item_id: usage_line_item&.id,
|
109
|
+
usage_balance: usage_pricing&.balanceUsed&.amount&.to_d,
|
110
|
+
usage_capped_amount: usage_pricing&.cappedAmount&.amount&.to_d,
|
111
|
+
usage_interval: usage_pricing&.interval,
|
112
|
+
usage_terms: usage_pricing&.terms,
|
113
|
+
)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
```
|
117
|
+
|
118
|
+
For usage examples see query and mutation below.
|
119
|
+
|
120
|
+
#### Example query
|
121
|
+
|
122
|
+
Definition:
|
123
|
+
```ruby
|
124
|
+
class GetAppSubscription
|
125
|
+
include ShopifyGraphql::Query
|
126
|
+
|
127
|
+
QUERY = <<~GRAPHQL
|
128
|
+
#{AppSubscriptionFields::FRAGMENT}
|
129
|
+
query($id: ID!) {
|
130
|
+
node(id: $id) {
|
131
|
+
... AppSubscriptionFields
|
132
|
+
}
|
133
|
+
}
|
134
|
+
GRAPHQL
|
135
|
+
|
136
|
+
def call(id:)
|
137
|
+
response = execute(QUERY, id: id)
|
138
|
+
response.data = AppSubscriptionFields.parse(response.data.node)
|
139
|
+
response
|
140
|
+
end
|
141
|
+
end
|
142
|
+
```
|
143
|
+
|
144
|
+
Usage:
|
145
|
+
```ruby
|
146
|
+
shopify_subscription = GetAppSubscription.call(id: @id).data
|
147
|
+
shopify_subscription.status
|
148
|
+
shopify_subscription.current_period_end
|
149
|
+
```
|
150
|
+
|
151
|
+
#### Example mutation
|
152
|
+
|
153
|
+
Definition:
|
154
|
+
```ruby
|
155
|
+
class CreateRecurringSubscription
|
156
|
+
include ShopifyGraphql::Mutation
|
157
|
+
|
158
|
+
MUTATION = <<~GRAPHQL
|
159
|
+
#{AppSubscriptionFields::FRAGMENT}
|
160
|
+
mutation appSubscriptionCreate(
|
161
|
+
$name: String!,
|
162
|
+
$lineItems: [AppSubscriptionLineItemInput!]!,
|
163
|
+
$returnUrl: URL!,
|
164
|
+
$trialDays: Int,
|
165
|
+
$test: Boolean
|
166
|
+
) {
|
167
|
+
appSubscriptionCreate(
|
168
|
+
name: $name,
|
169
|
+
lineItems: $lineItems,
|
170
|
+
returnUrl: $returnUrl,
|
171
|
+
trialDays: $trialDays,
|
172
|
+
test: $test
|
173
|
+
) {
|
174
|
+
appSubscription {
|
175
|
+
... AppSubscriptionFields
|
176
|
+
}
|
177
|
+
confirmationUrl
|
178
|
+
userErrors {
|
179
|
+
field
|
180
|
+
message
|
181
|
+
}
|
182
|
+
}
|
183
|
+
}
|
184
|
+
GRAPHQL
|
185
|
+
|
186
|
+
def call(name:, price:, return_url:, trial_days: nil, test: nil, interval: :monthly)
|
187
|
+
payload = { name: name, returnUrl: return_url }
|
188
|
+
plan_interval = (interval == :monthly) ? 'EVERY_30_DAYS' : 'ANNUAL'
|
189
|
+
payload[:lineItems] = [{
|
190
|
+
plan: {
|
191
|
+
appRecurringPricingDetails: {
|
192
|
+
price: { amount: price, currencyCode: 'USD' },
|
193
|
+
interval: plan_interval
|
194
|
+
}
|
195
|
+
}
|
196
|
+
}]
|
197
|
+
payload[:trialDays] = trial_days if trial_days
|
198
|
+
payload[:test] = test if test
|
199
|
+
|
200
|
+
response = execute(MUTATION, **payload)
|
201
|
+
response.data = response.data.appSubscriptionCreate
|
202
|
+
handle_user_errors(response.data)
|
203
|
+
response.data = parse_data(response.data)
|
204
|
+
response
|
205
|
+
end
|
206
|
+
|
207
|
+
private
|
208
|
+
|
209
|
+
def parse_data(data)
|
210
|
+
OpenStruct.new(
|
211
|
+
subscription: AppSubscriptionFields.parse(data.appSubscription),
|
212
|
+
confirmation_url: data.confirmationUrl,
|
213
|
+
)
|
214
|
+
end
|
215
|
+
end
|
216
|
+
```
|
217
|
+
|
218
|
+
Usage:
|
219
|
+
```ruby
|
220
|
+
response = CreateRecurringSubscription.call(
|
221
|
+
name: "Plan Name",
|
222
|
+
price: 10,
|
223
|
+
return_url: "RETURN URL",
|
224
|
+
trial_days: 3,
|
225
|
+
test: true,
|
226
|
+
).data
|
227
|
+
confirmation_url = response.confirmation_url
|
228
|
+
shopify_subscription = response.subscription
|
229
|
+
```
|
230
|
+
|
231
|
+
## Installation
|
232
|
+
|
233
|
+
In Gemfile, add:
|
234
|
+
```
|
235
|
+
gem 'shopify_graphql', github: 'kirillplatonov/shopify_graphql', branch: 'main'
|
236
|
+
```
|
237
|
+
|
238
|
+
This gem relies on `shopify_app` for authentication so no extra setup is required. But you still need to wrap your Graphql calls with `shop.with_shopify_session`:
|
239
|
+
```ruby
|
240
|
+
shop.with_shopify_session do
|
241
|
+
# your calls to graphql
|
242
|
+
end
|
243
|
+
```
|
244
|
+
|
245
|
+
The gem has built-in support for graphql webhooks (similar to `shopify_app`). To enable it add the following config to `config/initializers/shopify_app.rb`:
|
246
|
+
```ruby
|
247
|
+
ShopifyGraphql.configure do |config|
|
248
|
+
# Webhooks
|
249
|
+
webhooks_prefix = "https://#{Rails.configuration.app_host}/graphql_webhooks"
|
250
|
+
config.webhook_jobs_namespace = 'shopify/webhooks'
|
251
|
+
config.webhook_enabled_environments = ['production']
|
252
|
+
config.webhooks = [
|
253
|
+
{ topic: 'SHOP_UPDATE', address: "#{webhooks_prefix}/shop_update" },
|
254
|
+
{ topic: 'APP_SUBSCRIPTIONS_UPDATE', address: "#{webhooks_prefix}/app_subscriptions_update" },
|
255
|
+
{ topic: 'APP_UNINSTALLED', address: "#{webhooks_prefix}/app_uninstalled" },
|
256
|
+
]
|
257
|
+
end
|
258
|
+
```
|
259
|
+
|
260
|
+
## License
|
261
|
+
|
262
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module ShopifyGraphql
|
2
|
+
class MissingWebhookJobError < StandardError; end
|
3
|
+
|
4
|
+
class GraphqlWebhooksController < ActionController::Base
|
5
|
+
include ShopifyGraphql::WebhookVerification
|
6
|
+
|
7
|
+
def receive
|
8
|
+
params.permit!
|
9
|
+
webhook_job_klass.perform_later(shop_domain: shop_domain, webhook: webhook_params.to_h)
|
10
|
+
head(:ok)
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def webhook_params
|
16
|
+
params.except(:controller, :action, :type)
|
17
|
+
end
|
18
|
+
|
19
|
+
def webhook_job_klass
|
20
|
+
webhook_job_klass_name.safe_constantize || raise(ShopifyGraphql::MissingWebhookJobError)
|
21
|
+
end
|
22
|
+
|
23
|
+
def webhook_job_klass_name(type = webhook_type)
|
24
|
+
[webhook_namespace, "#{type}_job"].compact.join('/').classify
|
25
|
+
end
|
26
|
+
|
27
|
+
def webhook_type
|
28
|
+
params[:type]
|
29
|
+
end
|
30
|
+
|
31
|
+
def webhook_namespace
|
32
|
+
ShopifyGraphql.configuration.webhook_jobs_namespace
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
data/config/routes.rb
ADDED
@@ -0,0 +1,105 @@
|
|
1
|
+
module ShopifyGraphql
|
2
|
+
class Client
|
3
|
+
def initialize(api_version = ShopifyAPI::Base.api_version)
|
4
|
+
@api_version = api_version
|
5
|
+
end
|
6
|
+
|
7
|
+
def execute(query, **variables)
|
8
|
+
operation_name = variables.delete(:operation_name)
|
9
|
+
response = connection.post do |req|
|
10
|
+
req.body = {
|
11
|
+
query: query,
|
12
|
+
operationName: operation_name,
|
13
|
+
variables: variables,
|
14
|
+
}.to_json
|
15
|
+
end
|
16
|
+
response = handle_response(response)
|
17
|
+
ShopifyGraphql::Response.new(response)
|
18
|
+
end
|
19
|
+
|
20
|
+
def api_url
|
21
|
+
[ShopifyAPI::Base.site, @api_version.construct_graphql_path].join
|
22
|
+
end
|
23
|
+
|
24
|
+
def request_headers
|
25
|
+
ShopifyAPI::Base.headers
|
26
|
+
end
|
27
|
+
|
28
|
+
def connection
|
29
|
+
@connection ||= Faraday.new(url: api_url, headers: request_headers) do |conn|
|
30
|
+
conn.request :json
|
31
|
+
conn.response :json, parser_options: { object_class: OpenStruct }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def handle_response(response)
|
36
|
+
case response.status
|
37
|
+
when 200..400
|
38
|
+
handle_graphql_errors(response.body)
|
39
|
+
when 400
|
40
|
+
raise BadRequest.new(response.body, code: response.status)
|
41
|
+
when 401
|
42
|
+
raise UnauthorizedAccess.new(response.body, code: response.status)
|
43
|
+
when 402
|
44
|
+
raise PaymentRequired.new(response.body, code: response.status)
|
45
|
+
when 403
|
46
|
+
raise ForbiddenAccess.new(response.body, code: response.status)
|
47
|
+
when 404
|
48
|
+
raise ResourceNotFound.new(response.body, code: response.status)
|
49
|
+
when 405
|
50
|
+
raise MethodNotAllowed.new(response.body, code: response.status)
|
51
|
+
when 409
|
52
|
+
raise ResourceConflict.new(response.body, code: response.status)
|
53
|
+
when 410
|
54
|
+
raise ResourceGone.new(response.body, code: response.status)
|
55
|
+
when 412
|
56
|
+
raise PreconditionFailed.new(response.body, code: response.status)
|
57
|
+
when 422
|
58
|
+
raise ResourceInvalid.new(response.body, code: response.status)
|
59
|
+
when 429
|
60
|
+
raise TooManyRequests.new(response.body, code: response.status)
|
61
|
+
when 401...500
|
62
|
+
raise ClientError.new(response.body, code: response.status)
|
63
|
+
when 500...600
|
64
|
+
raise ServerError.new(response.body, code: response.status)
|
65
|
+
else
|
66
|
+
raise ConnectionError.new(response.body, "Unknown response code: #{response.status}")
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def handle_graphql_errors(response)
|
71
|
+
return response if response.errors.blank?
|
72
|
+
|
73
|
+
error = response.errors.first
|
74
|
+
error_message = error.message
|
75
|
+
error_code = error.extensions&.code
|
76
|
+
error_doc = error.extensions&.documentation
|
77
|
+
|
78
|
+
case error_code
|
79
|
+
when "THROTTLED"
|
80
|
+
raise TooManyRequests.new(response, error_message, code: error_code, doc: error_doc)
|
81
|
+
else
|
82
|
+
raise ConnectionError.new(response, error_message, code: error_code, doc: error_doc)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def handle_user_errors(response)
|
87
|
+
return response if response.userErrors.blank?
|
88
|
+
|
89
|
+
error = response.userErrors.first
|
90
|
+
error_message = error.message
|
91
|
+
error_fields = error.field
|
92
|
+
error_code = error.code
|
93
|
+
|
94
|
+
raise UserError.new(response, error_message, fields: error_fields, code: error_code)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
class << self
|
99
|
+
delegate :execute, :handle_user_errors, to: :client
|
100
|
+
|
101
|
+
def client(api_version = ShopifyAPI::Base.api_version)
|
102
|
+
Client.new(api_version)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module ShopifyGraphql
|
2
|
+
class Configuration
|
3
|
+
attr_accessor :webhooks
|
4
|
+
attr_accessor :webhook_jobs_namespace
|
5
|
+
attr_accessor :webhook_enabled_environments
|
6
|
+
attr_accessor :webhooks_manager_queue_name
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@webhooks_manager_queue_name = Rails.application.config.active_job.queue_name
|
10
|
+
@webhook_enabled_environments = ['production']
|
11
|
+
end
|
12
|
+
|
13
|
+
def has_webhooks?
|
14
|
+
webhooks.present?
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.configuration
|
19
|
+
@configuration ||= Configuration.new
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.configuration=(config)
|
23
|
+
@configuration = config
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.configure
|
27
|
+
yield configuration
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module ShopifyGraphql
|
2
|
+
module PayloadVerification
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
private
|
6
|
+
|
7
|
+
def shopify_hmac
|
8
|
+
request.headers['HTTP_X_SHOPIFY_HMAC_SHA256']
|
9
|
+
end
|
10
|
+
|
11
|
+
def hmac_valid?(data)
|
12
|
+
secrets = [ShopifyApp.configuration.secret, ShopifyApp.configuration.old_secret].reject(&:blank?)
|
13
|
+
|
14
|
+
secrets.any? do |secret|
|
15
|
+
digest = OpenSSL::Digest.new('sha256')
|
16
|
+
ActiveSupport::SecurityUtils.secure_compare(
|
17
|
+
shopify_hmac,
|
18
|
+
Base64.strict_encode64(OpenSSL::HMAC.digest(digest, secret, data))
|
19
|
+
)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module ShopifyGraphql
|
2
|
+
module WebhookVerification
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
include ShopifyGraphql::PayloadVerification
|
5
|
+
|
6
|
+
included do
|
7
|
+
skip_before_action :verify_authenticity_token, raise: false
|
8
|
+
before_action :verify_request
|
9
|
+
end
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
def verify_request
|
14
|
+
data = request.raw_post
|
15
|
+
return head(:unauthorized) unless hmac_valid?(data)
|
16
|
+
end
|
17
|
+
|
18
|
+
def shop_domain
|
19
|
+
request.headers['HTTP_X_SHOPIFY_SHOP_DOMAIN']
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module ShopifyGraphql
|
2
|
+
module RedactJobParams
|
3
|
+
private
|
4
|
+
|
5
|
+
def args_info(job)
|
6
|
+
log_disabled_classes = %[
|
7
|
+
ShopifyGraphql::CreateWebhooksJob
|
8
|
+
ShopifyGraphql::DestroyWebhooksJob
|
9
|
+
ShopifyGraphql::UpdateWebhooksJob
|
10
|
+
]
|
11
|
+
return "" if log_disabled_classes.include?(job.class.name)
|
12
|
+
super
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
class Engine < ::Rails::Engine
|
17
|
+
engine_name 'shopify_graphql'
|
18
|
+
isolate_namespace ShopifyGraphql
|
19
|
+
|
20
|
+
initializer "shopify_app.redact_job_params" do
|
21
|
+
ActiveSupport.on_load(:active_job) do
|
22
|
+
if ActiveJob::Base.respond_to?(:log_arguments?)
|
23
|
+
CreateWebhooksJob.log_arguments = false
|
24
|
+
DestroyWebhooksJob.log_arguments = false
|
25
|
+
UpdateWebhooksJob.log_arguments = false
|
26
|
+
elsif ActiveJob::Logging::LogSubscriber.private_method_defined?(:args_info)
|
27
|
+
ActiveJob::Logging::LogSubscriber.prepend(RedactJobParams)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module ShopifyGraphql
|
2
|
+
class ConnectionError < StandardError
|
3
|
+
attr_reader :response, :code, :doc, :fields
|
4
|
+
|
5
|
+
def initialize(response, message = nil, code: nil, doc: nil, fields: nil)
|
6
|
+
@response = response
|
7
|
+
@message = message
|
8
|
+
@code = code
|
9
|
+
@doc = doc
|
10
|
+
@fields = fields
|
11
|
+
end
|
12
|
+
|
13
|
+
def to_s
|
14
|
+
message = "Failed.".dup
|
15
|
+
message << " Response code = #{@code}." if @code
|
16
|
+
message << " Response message = #{@message}." if @message
|
17
|
+
message << " Documentation = #{@doc}." if @doc
|
18
|
+
message << " Fields = #{@fields}." if @fields
|
19
|
+
message
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# 4xx Client Error
|
24
|
+
class ClientError < ConnectionError
|
25
|
+
end
|
26
|
+
|
27
|
+
# 400 Bad Request
|
28
|
+
class BadRequest < ClientError # :nodoc:
|
29
|
+
end
|
30
|
+
|
31
|
+
# 401 Unauthorized
|
32
|
+
class UnauthorizedAccess < ClientError # :nodoc:
|
33
|
+
end
|
34
|
+
|
35
|
+
# 402 Payment Required
|
36
|
+
class PaymentRequired < ClientError # :nodoc:
|
37
|
+
end
|
38
|
+
|
39
|
+
# 403 Forbidden
|
40
|
+
class ForbiddenAccess < ClientError # :nodoc:
|
41
|
+
end
|
42
|
+
|
43
|
+
# 404 Not Found
|
44
|
+
class ResourceNotFound < ClientError # :nodoc:
|
45
|
+
end
|
46
|
+
|
47
|
+
# 405 Method Not Allowed
|
48
|
+
class MethodNotAllowed < ClientError # :nodoc:
|
49
|
+
end
|
50
|
+
|
51
|
+
# 409 Conflict
|
52
|
+
class ResourceConflict < ClientError # :nodoc:
|
53
|
+
end
|
54
|
+
|
55
|
+
# 410 Gone
|
56
|
+
class ResourceGone < ClientError # :nodoc:
|
57
|
+
end
|
58
|
+
|
59
|
+
# 412 Precondition Failed
|
60
|
+
class PreconditionFailed < ClientError # :nodoc:
|
61
|
+
end
|
62
|
+
|
63
|
+
# 429 Too Many Requests
|
64
|
+
class TooManyRequests < ClientError # :nodoc:
|
65
|
+
end
|
66
|
+
|
67
|
+
# Graphql userErrors
|
68
|
+
class UserError < ClientError # :nodoc:
|
69
|
+
end
|
70
|
+
|
71
|
+
# 5xx Server Error
|
72
|
+
class ServerError < ConnectionError
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module ShopifyGraphql
|
2
|
+
class CreateWebhooksJob < ActiveJob::Base
|
3
|
+
queue_as do
|
4
|
+
ShopifyGraphql.configuration.webhooks_manager_queue_name
|
5
|
+
end
|
6
|
+
|
7
|
+
def perform(shop_domain:, shop_token:)
|
8
|
+
api_version = ShopifyApp.configuration.api_version
|
9
|
+
webhooks = ShopifyGraphql.configuration.webhooks
|
10
|
+
|
11
|
+
ShopifyAPI::Session.temp(domain: shop_domain, token: shop_token, api_version: api_version) do
|
12
|
+
manager = WebhooksManager.new(webhooks)
|
13
|
+
manager.create_webhooks
|
14
|
+
end
|
15
|
+
rescue UnauthorizedAccess, ResourceNotFound, ForbiddenAccess, PaymentRequired
|
16
|
+
# Ignore
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module ShopifyGraphql
|
2
|
+
class DestroyWebhooksJob < ActiveJob::Base
|
3
|
+
queue_as do
|
4
|
+
ShopifyGraphql.configuration.webhooks_manager_queue_name
|
5
|
+
end
|
6
|
+
|
7
|
+
def perform(shop_domain:, shop_token:)
|
8
|
+
api_version = ShopifyApp.configuration.api_version
|
9
|
+
webhooks = ShopifyGraphql.configuration.webhooks
|
10
|
+
|
11
|
+
ShopifyAPI::Session.temp(domain: shop_domain, token: shop_token, api_version: api_version) do
|
12
|
+
manager = WebhooksManager.new(webhooks)
|
13
|
+
manager.destroy_webhooks
|
14
|
+
end
|
15
|
+
rescue UnauthorizedAccess, ResourceNotFound, ForbiddenAccess, PaymentRequired
|
16
|
+
# Ignore
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module ShopifyGraphql
|
2
|
+
class UpdateWebhooksJob < ActiveJob::Base
|
3
|
+
queue_as do
|
4
|
+
ShopifyGraphql.configuration.webhooks_manager_queue_name
|
5
|
+
end
|
6
|
+
|
7
|
+
def perform(shop_domain:, shop_token:)
|
8
|
+
api_version = ShopifyApp.configuration.api_version
|
9
|
+
webhooks = ShopifyGraphql.configuration.webhooks
|
10
|
+
|
11
|
+
ShopifyAPI::Session.temp(domain: shop_domain, token: shop_token, api_version: api_version) do
|
12
|
+
manager = WebhooksManager.new(webhooks)
|
13
|
+
manager.recreate_webhooks!
|
14
|
+
end
|
15
|
+
rescue UnauthorizedAccess, ResourceNotFound, ForbiddenAccess, PaymentRequired
|
16
|
+
# Ignore
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
module ShopifyGraphql
|
2
|
+
class WebhooksManager
|
3
|
+
class << self
|
4
|
+
def queue_create(shop_domain, shop_token)
|
5
|
+
ShopifyGraphql::CreateWebhooksJob.perform_later(
|
6
|
+
shop_domain: shop_domain,
|
7
|
+
shop_token: shop_token,
|
8
|
+
)
|
9
|
+
end
|
10
|
+
|
11
|
+
def queue_destroy(shop_domain, shop_token)
|
12
|
+
ShopifyGraphql::DestroyWebhooksJob.perform_later(
|
13
|
+
shop_domain: shop_domain,
|
14
|
+
shop_token: shop_token,
|
15
|
+
)
|
16
|
+
end
|
17
|
+
|
18
|
+
def queue_update(shop_domain, shop_token)
|
19
|
+
ShopifyGraphql::UpdateWebhooksJob.perform_later(
|
20
|
+
shop_domain: shop_domain,
|
21
|
+
shop_token: shop_token,
|
22
|
+
)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
attr_reader :required_webhooks
|
27
|
+
|
28
|
+
def initialize(webhooks)
|
29
|
+
@required_webhooks = webhooks
|
30
|
+
end
|
31
|
+
|
32
|
+
def recreate_webhooks!
|
33
|
+
destroy_webhooks
|
34
|
+
create_webhooks
|
35
|
+
end
|
36
|
+
|
37
|
+
def create_webhooks
|
38
|
+
return unless webhooks_enabled?
|
39
|
+
return unless required_webhooks.present?
|
40
|
+
|
41
|
+
required_webhooks.each do |webhook|
|
42
|
+
create_webhook(webhook) unless webhook_exists?(webhook[:topic])
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def destroy_webhooks
|
47
|
+
return unless webhooks_enabled?
|
48
|
+
|
49
|
+
current_webhooks.each do |webhook|
|
50
|
+
ShopifyGraphql::Webhook.delete(webhook.id)
|
51
|
+
end
|
52
|
+
|
53
|
+
@current_webhooks = nil
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def webhooks_enabled?
|
59
|
+
if ShopifyGraphql.configuration.webhook_enabled_environments.include?(Rails.env)
|
60
|
+
true
|
61
|
+
else
|
62
|
+
Rails.logger.info("[ShopifyGraphql] Webhooks disabled in #{Rails.env} environment. Check you config.")
|
63
|
+
false
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def create_webhook(attributes)
|
68
|
+
ShopifyGraphql::Webhook.create(
|
69
|
+
topic: attributes[:topic],
|
70
|
+
address: attributes[:address],
|
71
|
+
include_fields: attributes[:include_fields],
|
72
|
+
)
|
73
|
+
end
|
74
|
+
|
75
|
+
def webhook_exists?(topic)
|
76
|
+
current_webhooks.any? do |webhook|
|
77
|
+
webhook.topic == topic
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def current_webhooks
|
82
|
+
@current_webhooks ||= ShopifyGraphql::Webhook.all
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module ShopifyGraphql::Mutation
|
2
|
+
extend ActiveSupport::Concern
|
3
|
+
|
4
|
+
class_methods do
|
5
|
+
def call(**kwargs)
|
6
|
+
new.call(**kwargs)
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
delegate :execute, :handle_user_errors, to: :client
|
11
|
+
|
12
|
+
def client
|
13
|
+
@client ||= ShopifyGraphql::Client.new
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module ShopifyGraphql::Query
|
2
|
+
extend ActiveSupport::Concern
|
3
|
+
|
4
|
+
class_methods do
|
5
|
+
def call(**kwargs)
|
6
|
+
new.call(**kwargs)
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
delegate :execute, :handle_user_errors, to: :client
|
11
|
+
|
12
|
+
def client
|
13
|
+
@client ||= ShopifyGraphql::Client.new
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module ShopifyGraphql
|
2
|
+
class Shop
|
3
|
+
include Resource
|
4
|
+
|
5
|
+
class << self
|
6
|
+
def current
|
7
|
+
execute <<~GRAPHQL
|
8
|
+
query {
|
9
|
+
shop {
|
10
|
+
id
|
11
|
+
name
|
12
|
+
myshopifyDomain
|
13
|
+
description
|
14
|
+
plan { displayName }
|
15
|
+
}
|
16
|
+
}
|
17
|
+
GRAPHQL
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
module ShopifyGraphql
|
2
|
+
class Webhook
|
3
|
+
include Resource
|
4
|
+
|
5
|
+
ALL_WEBHOOKS_QUERY = <<~GRAPHQL
|
6
|
+
query {
|
7
|
+
webhookSubscriptions(first: 250) {
|
8
|
+
edges {
|
9
|
+
node {
|
10
|
+
id
|
11
|
+
topic
|
12
|
+
endpoint {
|
13
|
+
... on WebhookHttpEndpoint {
|
14
|
+
callbackUrl
|
15
|
+
}
|
16
|
+
}
|
17
|
+
}
|
18
|
+
}
|
19
|
+
}
|
20
|
+
}
|
21
|
+
GRAPHQL
|
22
|
+
|
23
|
+
CREATE_WEBHOOK_MUTATION = <<~GRAPHQL
|
24
|
+
mutation($topic: WebhookSubscriptionTopic!, $webhookSubscription: WebhookSubscriptionInput!) {
|
25
|
+
webhookSubscriptionCreate(topic: $topic, webhookSubscription: $webhookSubscription) {
|
26
|
+
webhookSubscription {
|
27
|
+
id
|
28
|
+
}
|
29
|
+
userErrors {
|
30
|
+
field
|
31
|
+
message
|
32
|
+
}
|
33
|
+
}
|
34
|
+
}
|
35
|
+
GRAPHQL
|
36
|
+
|
37
|
+
DELETE_WEBHOOK_MUTATION = <<~GRAPHQL
|
38
|
+
mutation($id: ID!) {
|
39
|
+
webhookSubscriptionDelete(id: $id) {
|
40
|
+
deletedWebhookSubscriptionId
|
41
|
+
userErrors {
|
42
|
+
field
|
43
|
+
message
|
44
|
+
}
|
45
|
+
}
|
46
|
+
}
|
47
|
+
GRAPHQL
|
48
|
+
|
49
|
+
class << self
|
50
|
+
def all
|
51
|
+
response = execute(ALL_WEBHOOKS_QUERY)
|
52
|
+
response.data.webhookSubscriptions.edges.map do |edge|
|
53
|
+
edge.node
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def create(topic:, address:, include_fields:)
|
58
|
+
response = execute(CREATE_WEBHOOK_MUTATION,
|
59
|
+
topic: topic,
|
60
|
+
webhookSubscription: {
|
61
|
+
callbackUrl: address,
|
62
|
+
format: 'JSON',
|
63
|
+
includeFields: include_fields,
|
64
|
+
},
|
65
|
+
)
|
66
|
+
response = response.data.webhookSubscriptionCreate
|
67
|
+
handle_user_errors(response)
|
68
|
+
end
|
69
|
+
|
70
|
+
def delete(id)
|
71
|
+
response = execute(DELETE_WEBHOOK_MUTATION, id: id)
|
72
|
+
response = response.data.webhookSubscriptionDelete
|
73
|
+
handle_user_errors(response)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module ShopifyGraphql
|
2
|
+
class Response
|
3
|
+
attr_reader :raw, :extensions, :errors
|
4
|
+
attr_accessor :data
|
5
|
+
|
6
|
+
def initialize(response)
|
7
|
+
@raw = response
|
8
|
+
@data = response.data
|
9
|
+
@extensions = response.extensions
|
10
|
+
@errors = response.errors
|
11
|
+
end
|
12
|
+
|
13
|
+
def points_left
|
14
|
+
extensions&.cost&.throttleStatus&.currentlyAvailable
|
15
|
+
end
|
16
|
+
|
17
|
+
def points_limit
|
18
|
+
extensions&.cost&.throttleStatus&.maximumAvailable
|
19
|
+
end
|
20
|
+
|
21
|
+
def points_restore_rate
|
22
|
+
extensions&.cost&.throttleStatus&.restoreRate
|
23
|
+
end
|
24
|
+
|
25
|
+
def points_maxed?(threshold: 0)
|
26
|
+
points_left < threshold
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'faraday'
|
2
|
+
require 'faraday_middleware'
|
3
|
+
|
4
|
+
require 'shopify_graphql/client'
|
5
|
+
require 'shopify_graphql/configuration'
|
6
|
+
require 'shopify_graphql/engine'
|
7
|
+
require 'shopify_graphql/exceptions'
|
8
|
+
require 'shopify_graphql/mutation'
|
9
|
+
require 'shopify_graphql/query'
|
10
|
+
require 'shopify_graphql/resource'
|
11
|
+
require 'shopify_graphql/response'
|
12
|
+
require 'shopify_graphql/version'
|
13
|
+
|
14
|
+
# controller concerns
|
15
|
+
require 'shopify_graphql/controller_concerns/payload_verification'
|
16
|
+
require 'shopify_graphql/controller_concerns/webhook_verification'
|
17
|
+
|
18
|
+
# jobs
|
19
|
+
require 'shopify_graphql/jobs/create_webhooks_job'
|
20
|
+
require 'shopify_graphql/jobs/destroy_webhooks_job'
|
21
|
+
require 'shopify_graphql/jobs/update_webhooks_job'
|
22
|
+
|
23
|
+
# managers
|
24
|
+
require 'shopify_graphql/managers/webhooks_manager'
|
25
|
+
|
26
|
+
# resources
|
27
|
+
require 'shopify_graphql/resources/shop'
|
28
|
+
require 'shopify_graphql/resources/webhook'
|
metadata
ADDED
@@ -0,0 +1,129 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: shopify_graphql
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Kirill Platonov
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-10-02 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 5.2.0
|
20
|
+
- - "<"
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: 7.0.0
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: 5.2.0
|
30
|
+
- - "<"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: 7.0.0
|
33
|
+
- !ruby/object:Gem::Dependency
|
34
|
+
name: shopify_app
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - ">"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '17.0'
|
40
|
+
type: :runtime
|
41
|
+
prerelease: false
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - ">"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '17.0'
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: faraday
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - ">="
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '1.0'
|
54
|
+
type: :runtime
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - ">="
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '1.0'
|
61
|
+
- !ruby/object:Gem::Dependency
|
62
|
+
name: faraday_middleware
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - ">="
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: '0'
|
68
|
+
type: :runtime
|
69
|
+
prerelease: false
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
71
|
+
requirements:
|
72
|
+
- - ">="
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: '0'
|
75
|
+
description:
|
76
|
+
email:
|
77
|
+
- mail@kirillplatonov.com
|
78
|
+
executables: []
|
79
|
+
extensions: []
|
80
|
+
extra_rdoc_files: []
|
81
|
+
files:
|
82
|
+
- LICENSE.txt
|
83
|
+
- README.md
|
84
|
+
- app/controllers/shopify_graphql/graphql_webhooks_controller.rb
|
85
|
+
- config/routes.rb
|
86
|
+
- lib/shopify_graphql.rb
|
87
|
+
- lib/shopify_graphql/client.rb
|
88
|
+
- lib/shopify_graphql/configuration.rb
|
89
|
+
- lib/shopify_graphql/controller_concerns/payload_verification.rb
|
90
|
+
- lib/shopify_graphql/controller_concerns/webhook_verification.rb
|
91
|
+
- lib/shopify_graphql/engine.rb
|
92
|
+
- lib/shopify_graphql/exceptions.rb
|
93
|
+
- lib/shopify_graphql/jobs/create_webhooks_job.rb
|
94
|
+
- lib/shopify_graphql/jobs/destroy_webhooks_job.rb
|
95
|
+
- lib/shopify_graphql/jobs/update_webhooks_job.rb
|
96
|
+
- lib/shopify_graphql/managers/webhooks_manager.rb
|
97
|
+
- lib/shopify_graphql/mutation.rb
|
98
|
+
- lib/shopify_graphql/query.rb
|
99
|
+
- lib/shopify_graphql/resource.rb
|
100
|
+
- lib/shopify_graphql/resources/shop.rb
|
101
|
+
- lib/shopify_graphql/resources/webhook.rb
|
102
|
+
- lib/shopify_graphql/response.rb
|
103
|
+
- lib/shopify_graphql/version.rb
|
104
|
+
- lib/tasks/shopify_graphql_tasks.rake
|
105
|
+
homepage: https://github.com/kirillplatonov/shopify_graphql
|
106
|
+
licenses:
|
107
|
+
- MIT
|
108
|
+
metadata:
|
109
|
+
allowed_push_host: https://rubygems.org
|
110
|
+
post_install_message:
|
111
|
+
rdoc_options: []
|
112
|
+
require_paths:
|
113
|
+
- lib
|
114
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
115
|
+
requirements:
|
116
|
+
- - ">="
|
117
|
+
- !ruby/object:Gem::Version
|
118
|
+
version: 2.7.0
|
119
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
120
|
+
requirements:
|
121
|
+
- - ">="
|
122
|
+
- !ruby/object:Gem::Version
|
123
|
+
version: '0'
|
124
|
+
requirements: []
|
125
|
+
rubygems_version: 3.2.22
|
126
|
+
signing_key:
|
127
|
+
specification_version: 4
|
128
|
+
summary: Less painful way to work with Shopify Graphql API in Ruby.
|
129
|
+
test_files: []
|