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 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: []