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