shopify_graphql 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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,5 @@
1
+ ShopifyGraphql::Engine.routes.draw do
2
+ namespace :graphql_webhooks do
3
+ post ':type' => :receive
4
+ end
5
+ end
@@ -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,13 @@
1
+ module ShopifyGraphql
2
+ module Resource
3
+ extend ActiveSupport::Concern
4
+
5
+ class_methods do
6
+ delegate :execute, :handle_user_errors, to: :client
7
+
8
+ def client
9
+ ShopifyGraphql::Client.new
10
+ end
11
+ end
12
+ end
13
+ 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,3 @@
1
+ module ShopifyGraphql
2
+ VERSION = "0.1.0"
3
+ 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'
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :shopify_graphql do
3
+ # # Task goes here
4
+ # end
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: []