shopify_graphql 2.5.2 → 2.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 835956a5fd0a03ab1bdee46e0eebfe922f5ae1fb752e370f24eae2ddeba94c89
4
- data.tar.gz: a42109e905b9310e95553432b504e5f081c4db92b2b4d1aee4c7fc1cc15d25f6
3
+ metadata.gz: bcbd21fda0033f2ada1d0f6c86e19d1bc46bb8d5a787cf868c7f19d877842d07
4
+ data.tar.gz: 932aaf0b971c2b463eff279dbe9c00512e18b8c26863aa21f71eec6b7cb9b68f
5
5
  SHA512:
6
- metadata.gz: fa74302c607542f2c28ac9103c5d19ac30be2a5625976b817da5c702c4281f62024a5c51f7b1604fbe5db21770978047a8e1315738a0cdd98c267b66440b71d7
7
- data.tar.gz: c572fd99e4f231f9b4edb7caace28daacfa92edb91ca0da993863dd2dbed7574ed9e797e86ff88c3a223a406291ea3122c2c2ae83dd3c16639264cc93b9c7894
6
+ metadata.gz: 88f2a985ebb14ca11d9f11ad8b9e1a7d212b688d648d47438226578882f2e29c665301633c4f7cf4450f2cefa110ff45845d62584daf6ad8a148bc4bbffda441
7
+ data.tar.gz: ae06c0ab85d3a1e3cb853c1511fa6e0dc97fc5f946cec41e010a846bd050d66c1a3ef86c717e16632d59807697eb40ff294255a6f4126d151fc9de4032090fd9
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Shopify Graphql
2
2
 
3
- Less painful way to work with [Shopify Graphql API](https://shopify.dev/api/admin-graphql) in Ruby. This library is a tiny wrapper on top of [`shopify_api`](https://github.com/Shopify/shopify-api-ruby) gem. It provides a simple API for Graphql calls, better error handling, and Graphql webhooks integration.
3
+ Less painful way to work with [Shopify Graphql API](https://shopify.dev/api/admin-graphql) in Ruby. This library is a tiny wrapper on top of [`shopify_api`](https://github.com/Shopify/shopify-api-ruby) gem. It provides a simple API for Graphql calls, better error handling, and Graphql webhooks integration (deprecated).
4
4
 
5
5
  ## Features
6
6
 
@@ -9,13 +9,13 @@ Less painful way to work with [Shopify Graphql API](https://shopify.dev/api/admi
9
9
  - ActiveResource-like error handling
10
10
  - Graphql and user error handlers
11
11
  - Auto-conversion of responses to OpenStruct
12
- - Graphql webhooks integration for Rails
12
+ - Graphql webhooks integration for Rails (deprecated, see [Graphql webhooks](#graphql-webhooks-deprecated))
13
13
  - Wrappers for Graphql rate limit extensions
14
14
  - Built-in calls for common Graphql calls
15
15
 
16
16
  ## Dependencies
17
17
 
18
- - [`shopify_api`](https://github.com/Shopify/shopify-api-ruby) v10+
18
+ - [`shopify_api`](https://github.com/Shopify/shopify-api-ruby) v13.4+
19
19
  - [`shopify_app`](https://github.com/Shopify/shopify_app) v19+
20
20
 
21
21
  > For `shopify_api` < v10 use [`0-4-stable`](https://github.com/kirillplatonov/shopify_graphql/tree/0-4-stable) branch.
@@ -755,12 +755,21 @@ ShopifyGraphql.handle_user_errors(response)
755
755
  puts shop.shop_locales
756
756
  ```
757
757
 
758
+ Or with products count (requires `read_products` scope):
759
+
760
+ ```rb
761
+ shop = ShopifyGraphql::CurrentShop.call(with_products_count: true)
762
+ puts shop.products_count
763
+ ```
764
+
765
+ `plan_name` and `plan_display_name` are deprecated and will be removed in v3.0. They're derived from the deprecated `plan.displayName` field. Use `plan_public_display_name`, `plan_shopify_plus` and `plan_partner_development` instead.
766
+
758
767
  - `ShopifyGraphql::CancelSubscription`
759
768
  - `ShopifyGraphql::CreateRecurringSubscription`
760
769
  - `ShopifyGraphql::CreateUsageSubscription`
761
770
  - `ShopifyGraphql::GetAppSubscription`
762
- - `ShopifyGraphql::UpsertPrivateMetafield`
763
- - `ShopifyGraphql::DeletePrivateMetafield`
771
+ - `ShopifyGraphql::UpsertPrivateMetafield` (deprecated, see [Deprecated in 2.6](#deprecated-in-26-removed-in-30))
772
+ - `ShopifyGraphql::DeletePrivateMetafield` (deprecated, see [Deprecated in 2.6](#deprecated-in-26-removed-in-30))
764
773
  - `ShopifyGraphql::CreateBulkMutation`
765
774
  - `ShopifyGraphql::CreateBulkQuery`
766
775
  - `ShopifyGraphql::CreateStagedUploads`
@@ -776,6 +785,7 @@ The gem exposes Graphql rate limit extensions in response object:
776
785
  - `points_limit`
777
786
  - `points_restore_rate`
778
787
  - `query_cost`
788
+ - `requested_query_cost`
779
789
 
780
790
  And adds a helper to check if available points lower than threshold (useful for implementing API backoff):
781
791
 
@@ -792,6 +802,95 @@ response.query_cost # => 1
792
802
  response.points_maxed?(threshold: 100) # => false
793
803
  ```
794
804
 
805
+ ## Error handling
806
+
807
+ Failed calls raise `ShopifyGraphql::ConnectionError` or one of its subclasses. GraphQL errors come back with HTTP 200 and are mapped by the first error's `extensions.code`:
808
+
809
+ | Class | Raised for |
810
+ | --- | --- |
811
+ | `TooManyRequests` | `THROTTLED`, HTTP 429 and 430 |
812
+ | `ServerError` | `INTERNAL_SERVER_ERROR`, HTTP 5xx, invalid JSON and network errors |
813
+ | `AccessDenied` | `ACCESS_DENIED` (missing access scope) |
814
+ | `MaxCostExceeded` | `MAX_COST_EXCEEDED` (query is over the single query cost limit) |
815
+ | `ShopInactive` | `SHOP_INACTIVE` |
816
+ | `ShopPendingTermination` | `SHOP_PENDING_TERMINATION` |
817
+ | `BadRequest` | HTTP 400 |
818
+ | `UnauthorizedAccess` | HTTP 401 |
819
+ | `PaymentRequired` | HTTP 402 (shop is frozen) |
820
+ | `ForbiddenAccess` | HTTP 403 (shop is marked as fraudulent) |
821
+ | `NonExpiringTokenRejected` | HTTP 403 for a public app's non-expiring offline access token, from January 1, 2027. Subclass of `ForbiddenAccess` |
822
+ | `ResourceNotFound` | HTTP 404 |
823
+ | `MethodNotAllowed`, `ResourceConflict`, `ResourceGone`, `PreconditionFailed` | HTTP 405, 409, 410, 412 |
824
+ | `ShopLocked` | HTTP 423 |
825
+ | `ClientError` | Other HTTP 4xx |
826
+ | `UserError` | `userErrors`, from `handle_user_errors` |
827
+ | `ConnectionError` | Other GraphQL error codes |
828
+
829
+ Everything except `ServerError`, `UserError` and plain `ConnectionError` is a `ClientError`.
830
+
831
+ Errors raised from a GraphQL response carry its partial `data`, all of its `errors`, and the same cost readers as the response (`query_cost`, `requested_query_cost`, `points_left`, `points_limit`, `points_restore_rate`). `errors` is always an Array, empty when there are none. `data` and the cost readers return `nil` when the information isn't available, e.g. for HTTP and network errors.
832
+
833
+ ```rb
834
+ retries = 0
835
+ begin
836
+ response = GetProduct.call(id: "gid://shopify/Product/PRODUCT_GID")
837
+ rescue ShopifyGraphql::TooManyRequests => error
838
+ retries += 1
839
+ raise if retries > 3
840
+
841
+ # Throttled queries don't run, so there's no query_cost. Wait until the requested
842
+ # cost is restored, or 1 second if the response has no cost data.
843
+ wait = 1.0
844
+ if error.requested_query_cost && error.points_left && error.points_restore_rate
845
+ wait = (error.requested_query_cost - error.points_left) / error.points_restore_rate.to_f
846
+ end
847
+ sleep(wait.clamp(1, 60))
848
+ retry
849
+ rescue ShopifyGraphql::ConnectionError => error
850
+ error.data # => partial data, e.g. when a field returns ACCESS_DENIED
851
+ error.errors # => all GraphQL errors
852
+ raise
853
+ end
854
+ ```
855
+
856
+ `UserError` carries the mutation payload as `data`, its `userErrors` as `errors`, and the cost of the mutation:
857
+
858
+ ```rb
859
+ begin
860
+ UpdateProduct.call(input: { id: "gid://shopify/Product/123", title: "" })
861
+ rescue ShopifyGraphql::UserError => error
862
+ error.query_cost # => 10
863
+ error.errors.map(&:message) # => ["Title can't be blank"]
864
+ end
865
+ ```
866
+
867
+ Wrappers that include `ShopifyGraphql::Query` or `ShopifyGraphql::Mutation` attach the cost automatically. `ShopifyGraphql.execute` and `ShopifyGraphql.handle_user_errors` use a new client on each call, so pass the response explicitly:
868
+
869
+ ```rb
870
+ response = ShopifyGraphql.execute(PRODUCT_UPDATE_MUTATION, input: input)
871
+ ShopifyGraphql.handle_user_errors(response.data.productUpdate, response: response)
872
+ ```
873
+
874
+ ### Unavailable shops
875
+
876
+ `UnauthorizedAccess`, `PaymentRequired`, `ShopLocked`, `ShopPendingTermination` and `ShopInactive` include `ShopifyGraphql::ShopUnavailable`. Rescue it to stop working with a shop that uninstalled the app or was frozen, locked or closed:
877
+
878
+ ```rb
879
+ class SyncProductsJob < ApplicationJob
880
+ discard_on ShopifyGraphql::ShopUnavailable
881
+
882
+ def perform(shop)
883
+ shop.with_shopify_session do
884
+ GetProducts.call
885
+ end
886
+ end
887
+ end
888
+ ```
889
+
890
+ Shopify also returns 401 for an expired access token. If you use expiring offline access tokens, a 401 can mean the token wasn't refreshed, so refresh it before treating the shop as uninstalled.
891
+
892
+ `ForbiddenAccess`, `NonExpiringTokenRejected` and `ResourceNotFound` aren't included, so handle them separately. A 403 means the shop was marked as fraudulent or, from January 1, 2027, that the app sent a non-expiring access token, which would affect every shop at once. A 404 is also raised for null nodes.
893
+
795
894
  ## Custom apps
796
895
 
797
896
  In custom apps, if you're using `shopify_app` gem, then the setup is similar public apps. Except `Shop` model which must include class method to make queries to your store:
@@ -855,10 +954,22 @@ Shop.with_shopify_session do
855
954
  end
856
955
  ```
857
956
 
957
+ ## Deprecated in 2.6, removed in 3.0
958
+
959
+ These APIs warn through `ShopifyGraphql.deprecator`, once per process for each API. On Rails 7.1+ it's registered as `Rails.application.deprecators[:shopify_graphql]`, so your `config.active_support.deprecation` settings apply to it.
960
+
961
+ | Deprecated | Use instead |
962
+ | --- | --- |
963
+ | `ShopifyGraphql::UpsertPrivateMetafield`, `ShopifyGraphql::DeletePrivateMetafield` | App-owned metafields: `metafieldsSet` and `metafieldsDelete` with an `$app` namespace. Shopify has removed `privateMetafieldUpsert` and `privateMetafieldDelete` from the Admin API. |
964
+ | [Graphql webhooks](#graphql-webhooks-deprecated): `ShopifyGraphql::WebhooksManager`, `ShopifyGraphql::Webhook`, `CreateWebhooksJob`, `DestroyWebhooksJob`, `UpdateWebhooksJob`, `WebhookVerification`, the `graphql_webhooks` route and the `config.webhook*` options | `shopify_app` [webhooks](https://github.com/Shopify/shopify_app/blob/main/docs/shopify_app/webhooks.md) or app-specific subscriptions in `shopify.app.toml` |
965
+ | `ShopifyGraphql::Resource`, `ShopifyGraphql::Shop.current` | `ShopifyGraphql::CurrentShop`, or your own classes that include `ShopifyGraphql::Query` or `ShopifyGraphql::Mutation` |
966
+ | `ShopifyGraphql::PLAN_TO_DISPLAY_NAME`, `ShopifyGraphql::DISPLAY_NAME_TO_PLAN`, `ShopifyGraphql.normalize_plan_display_name` | `plan_public_display_name`, `plan_shopify_plus` and `plan_partner_development` from `ShopifyGraphql::CurrentShop` |
967
+ | `plan_name` and `plan_display_name` from `ShopifyGraphql::CurrentShop` (no runtime warning) | `plan_public_display_name`, `plan_shopify_plus` and `plan_partner_development` |
968
+
858
969
  ## Graphql webhooks (deprecated)
859
970
 
860
971
  > [!WARNING]
861
- > ShopifyGraphql webhooks are deprecated and will be removed in v3.0. Please use `shopify_app` gem for handling webhooks. See [`shopify_app` documentation](https://github.com/Shopify/shopify_app/blob/main/docs/shopify_app/webhooks.md) for more details.
972
+ > ShopifyGraphql webhooks are deprecated and will be removed in v3.0. Please use `shopify_app` gem for handling webhooks or declare app-specific subscriptions in `shopify.app.toml`. See [`shopify_app` documentation](https://github.com/Shopify/shopify_app/blob/main/docs/shopify_app/webhooks.md) for more details.
862
973
 
863
974
  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`:
864
975
 
@@ -18,8 +18,29 @@ module ShopifyGraphql
18
18
  }
19
19
  GRAPHQL
20
20
 
21
- def call(query:)
22
- response = execute(MUTATION, query: query)
21
+ # groupObjects is available since API version 2025-07
22
+ MUTATION_WITH_GROUP_OBJECTS = <<~GRAPHQL
23
+ mutation($query: String!, $groupObjects: Boolean!) {
24
+ bulkOperationRunQuery(query: $query, groupObjects: $groupObjects) {
25
+ bulkOperation {
26
+ id
27
+ status
28
+ }
29
+ userErrors {
30
+ code
31
+ field
32
+ message
33
+ }
34
+ }
35
+ }
36
+ GRAPHQL
37
+
38
+ def call(query:, group_objects: nil)
39
+ response = if group_objects.nil?
40
+ execute(MUTATION, query: query)
41
+ else
42
+ execute(MUTATION_WITH_GROUP_OBJECTS, query: query, groupObjects: group_objects)
43
+ end
23
44
  response.data = response.data.bulkOperationRunQuery
24
45
  handle_user_errors(response.data)
25
46
  response
@@ -13,14 +13,15 @@ module ShopifyGraphql
13
13
  primaryDomain {
14
14
  host
15
15
  }
16
- #CREATED_AT#
17
- #UPDATED_AT#
18
- #SHOP_OWNER_NAME#
16
+ createdAt
17
+ updatedAt
18
+ shopOwnerName
19
19
  currencyCode
20
- billingAddress {
20
+ shopAddress {
21
21
  country
22
22
  countryCodeV2
23
23
  province
24
+ provinceCode
24
25
  city
25
26
  address1
26
27
  address2
@@ -33,6 +34,9 @@ module ShopifyGraphql
33
34
  ianaTimezone
34
35
  plan {
35
36
  displayName
37
+ publicDisplayName
38
+ shopifyPlus
39
+ partnerDevelopment
36
40
  }
37
41
  currencyFormats {
38
42
  moneyFormat
@@ -47,14 +51,22 @@ module ShopifyGraphql
47
51
  checkoutApiSupported
48
52
  transactionalSmsDisabled
49
53
  enabledPresentmentCurrencies
50
- #SMS_CONSENT#
54
+ marketingSmsConsentEnabledAtCheckout
51
55
  resourceLimits {
52
56
  maxProductOptions
53
57
  maxProductVariants
54
58
  }
55
- #FEATURES#
59
+ }
60
+ currentAppInstallation {
61
+ id
62
+ }
63
+ onlineStore {
64
+ passwordProtection {
65
+ enabled
66
+ }
56
67
  }
57
68
  #LOCALES_SUBQUERY#
69
+ #PRODUCTS_COUNT_SUBQUERY#
58
70
  }
59
71
  GRAPHQL
60
72
  LOCALES_SUBQUERY = <<~GRAPHQL
@@ -63,48 +75,29 @@ module ShopifyGraphql
63
75
  primary
64
76
  }
65
77
  GRAPHQL
66
- FEATURES_SUBQUERY = <<~GRAPHQL
67
- features {
68
- unifiedMarkets
78
+ PRODUCTS_COUNT_SUBQUERY = <<~GRAPHQL
79
+ productsCount(limit: null) {
80
+ count
69
81
  }
70
82
  GRAPHQL
71
83
 
72
- def call(with_locales: false)
73
- query = prepare_query(QUERY, with_locales: with_locales)
84
+ def call(with_locales: false, with_products_count: false)
85
+ query = prepare_query(QUERY, with_locales: with_locales, with_products_count: with_products_count)
74
86
  response = execute(query)
75
- parse_data(response, with_locales: with_locales)
87
+ parse_data(response, with_locales: with_locales, with_products_count: with_products_count)
76
88
  end
77
89
 
78
90
  private
79
91
 
80
- def prepare_query(query, with_locales:)
92
+ def prepare_query(query, with_locales:, with_products_count: false)
81
93
  query = query.gsub("#LOCALES_SUBQUERY#", with_locales ? LOCALES_SUBQUERY : "")
82
- if ShopifyAPI::Context.api_version.in?(%w[2024-01 2024-04 2024-07])
83
- query.gsub!("#SHOP_OWNER_NAME#", "")
84
- else
85
- query.gsub!("#SHOP_OWNER_NAME#", "shopOwnerName")
86
- end
87
- if ShopifyAPI::Context.api_version.in?(%w[2024-01])
88
- query.gsub!("#CREATED_AT#", "")
89
- query.gsub!("#UPDATED_AT#", "")
90
- query.gsub!("#SMS_CONSENT#", "")
91
- else
92
- query.gsub!("#CREATED_AT#", "createdAt")
93
- query.gsub!("#UPDATED_AT#", "updatedAt")
94
- query.gsub!("#SMS_CONSENT#", "marketingSmsConsentEnabledAtCheckout")
95
- end
96
- if ShopifyAPI::Context.api_version.in?(%w[2024-01 2024-04 2024-07 2024-10 2025-01])
97
- query.gsub!("#FEATURES#", "")
98
- else
99
- query.gsub!("#FEATURES#", FEATURES_SUBQUERY)
100
- end
101
- query
94
+ query.gsub("#PRODUCTS_COUNT_SUBQUERY#", with_products_count ? PRODUCTS_COUNT_SUBQUERY : "")
102
95
  end
103
96
 
104
- def parse_data(response, with_locales: false)
97
+ def parse_data(response, with_locales: false, with_products_count: false)
105
98
  data = response.data
106
- plan_display_name = ShopifyGraphql.normalize_plan_display_name(data.shop.plan.displayName)
107
- plan_name = ShopifyGraphql::DISPLAY_NAME_TO_PLAN[plan_display_name]
99
+ plan_display_name = PlanNames.normalize_display_name(data.shop.plan.displayName)
100
+ plan_name = PlanNames::DISPLAY_NAME_TO_PLAN[plan_display_name]
108
101
  shop_data = OpenStruct.new(
109
102
  id: data.shop.id.split("/").last.to_i,
110
103
  name: data.shop.name,
@@ -116,22 +109,25 @@ module ShopifyGraphql
116
109
  updated_at: data.shop.updatedAt,
117
110
  shop_owner: data.shop.shopOwnerName,
118
111
  currency: data.shop.currencyCode,
119
- country_name: data.shop.billingAddress.country,
120
- country: data.shop.billingAddress.countryCodeV2,
121
- country_code: data.shop.billingAddress.countryCodeV2,
122
- province: data.shop.billingAddress.province,
123
- province_code: data.shop.billingAddress.provinceCode,
124
- city: data.shop.billingAddress.city,
125
- address1: data.shop.billingAddress.address1,
126
- address2: data.shop.billingAddress.address2,
127
- zip: data.shop.billingAddress.zip,
128
- latitude: data.shop.billingAddress.latitude,
129
- longitude: data.shop.billingAddress.longitude,
130
- phone: data.shop.billingAddress.phone,
112
+ country_name: data.shop.shopAddress.country,
113
+ country: data.shop.shopAddress.countryCodeV2,
114
+ country_code: data.shop.shopAddress.countryCodeV2,
115
+ province: data.shop.shopAddress.province,
116
+ province_code: data.shop.shopAddress.provinceCode,
117
+ city: data.shop.shopAddress.city,
118
+ address1: data.shop.shopAddress.address1,
119
+ address2: data.shop.shopAddress.address2,
120
+ zip: data.shop.shopAddress.zip,
121
+ latitude: data.shop.shopAddress.latitude,
122
+ longitude: data.shop.shopAddress.longitude,
123
+ phone: data.shop.shopAddress.phone,
131
124
  timezone: data.shop.timezoneAbbreviation,
132
125
  iana_timezone: data.shop.ianaTimezone,
133
126
  plan_name: plan_name,
134
127
  plan_display_name: plan_display_name,
128
+ plan_public_display_name: data.shop.plan.publicDisplayName,
129
+ plan_shopify_plus: data.shop.plan.shopifyPlus,
130
+ plan_partner_development: data.shop.plan.partnerDevelopment,
135
131
  money_format: data.shop.currencyFormats.moneyFormat,
136
132
  money_in_emails_format: data.shop.currencyFormats.moneyInEmailsFormat,
137
133
  money_with_currency_format: data.shop.currencyFormats.moneyWithCurrencyFormat,
@@ -146,7 +142,10 @@ module ShopifyGraphql
146
142
  marketing_sms_consent_enabled_at_checkout: data.shop.marketingSmsConsentEnabledAtCheckout,
147
143
  max_product_options: data.shop.resourceLimits.maxProductOptions,
148
144
  max_product_variants: data.shop.resourceLimits.maxProductVariants,
149
- unified_markets: !!data.shop&.features&.unifiedMarkets,
145
+ unified_markets: true,
146
+ app_installation_id: data.currentAppInstallation&.id,
147
+ password_enabled: !!data.onlineStore&.passwordProtection&.enabled,
148
+ products_count: with_products_count ? data.productsCount&.count : nil,
150
149
  graphql_points_limit: response.points_limit
151
150
  )
152
151
  if with_locales
@@ -15,6 +15,9 @@ module ShopifyGraphql
15
15
  GRAPHQL
16
16
 
17
17
  def call(namespace:, key:, owner: nil)
18
+ ShopifyGraphql::Deprecation.warn_once("ShopifyGraphql::DeletePrivateMetafield", "ShopifyGraphql::DeletePrivateMetafield is deprecated and will be removed in v3.0. " \
19
+ "Shopify has removed privateMetafieldDelete from the Admin API. Please use app-owned metafields (metafieldsDelete with an $app namespace) instead.")
20
+
18
21
  input = {namespace: namespace, key: key}
19
22
  input[:owner] = owner if owner
20
23
 
@@ -7,14 +7,17 @@ module ShopifyGraphql
7
7
  node(id: $id) {
8
8
  ... on BulkOperation {
9
9
  id
10
+ type
10
11
  status
11
12
  errorCode
12
13
  createdAt
13
14
  completedAt
14
15
  fileSize
15
16
  url
17
+ partialDataUrl
16
18
  objectCount
17
19
  rootObjectCount
20
+ query
18
21
  }
19
22
  }
20
23
  }
@@ -35,13 +38,17 @@ module ShopifyGraphql
35
38
 
36
39
  OpenStruct.new(
37
40
  id: data.id,
41
+ type: data.type,
38
42
  status: data.status,
39
43
  error_code: data.errorCode,
40
44
  created_at: Time.find_zone("UTC").parse(data.createdAt),
41
45
  completed_at: data.completedAt ? Time.find_zone("UTC").parse(data.completedAt) : nil,
46
+ file_size: data.fileSize ? data.fileSize.to_i : nil,
42
47
  url: data.url,
48
+ partial_data_url: data.partialDataUrl,
43
49
  object_count: data.objectCount.to_i,
44
50
  root_object_count: data.rootObjectCount.to_i,
51
+ query: data.query,
45
52
  )
46
53
  end
47
54
  end
@@ -21,6 +21,9 @@ module ShopifyGraphql
21
21
  GRAPHQL
22
22
 
23
23
  def call(namespace:, key:, value:, owner: nil)
24
+ ShopifyGraphql::Deprecation.warn_once("ShopifyGraphql::UpsertPrivateMetafield", "ShopifyGraphql::UpsertPrivateMetafield is deprecated and will be removed in v3.0. " \
25
+ "Shopify has removed privateMetafieldUpsert from the Admin API. Please use app-owned metafields (metafieldsSet with an $app namespace) instead.")
26
+
24
27
  input = {namespace: namespace, key: key}
25
28
  input[:owner] = owner if owner
26
29
 
@@ -1,29 +1,4 @@
1
1
  module ShopifyGraphql
2
- # Mapping from deprecated plan_name to plan_display_name
3
- PLAN_TO_DISPLAY_NAME = {
4
- "trial" => "trial",
5
- "frozen" => "frozen",
6
- "fraudulent" => "cancelled",
7
- "shopify_alumni" => "shopify_alumni",
8
- "affiliate" => "development",
9
- "basic" => "basic",
10
- "professional" => "shopify",
11
- "npo_full" => "npo_full",
12
- "shopify_plus" => "shopify_plus",
13
- "staff" => "staff",
14
- "unlimited" => "advanced",
15
- "retail" => "retail",
16
- "cancelled" => "cancelled",
17
- "dormant" => "pause_and_build",
18
- "starter_2022" => "shopify_starter",
19
- "plus_partner_sandbox" => "shopify_plus_partner_sandbox",
20
- "paid_trial" => "extended_trial",
21
- "partner_test" => "developer_preview",
22
- "open_learning" => "open_learning",
23
- "staff_business" => "staff_business"
24
- }
25
- DISPLAY_NAME_TO_PLAN = PLAN_TO_DISPLAY_NAME.invert
26
-
27
2
  class Client
28
3
  NETWORK_ERRORS = [
29
4
  Errno::ECONNRESET,
@@ -39,6 +14,9 @@ module ShopifyGraphql
39
14
  EOFError
40
15
  ].freeze
41
16
 
17
+ # Stable part of the 403 errors message for a non-expiring offline access token
18
+ NON_EXPIRING_TOKEN_MESSAGE = /non-expiring access tokens are no longer accepted/i
19
+
42
20
  def client
43
21
  session = ShopifyAPI::Context.active_session
44
22
  if @client.nil? || @access_token != session&.access_token || @shop != session&.shop
@@ -50,10 +28,11 @@ module ShopifyGraphql
50
28
  end
51
29
 
52
30
  def execute(query, headers: nil, **variables)
31
+ @last_response = nil
53
32
  response = client.query(query: query, variables: variables, headers: headers)
54
- Response.new(handle_response(response))
33
+ @last_response = Response.new(handle_response(response))
55
34
  rescue ShopifyAPI::Errors::HttpResponseError => e
56
- Response.new(handle_response(e.response, e))
35
+ @last_response = Response.new(handle_response(e.response, e))
57
36
  rescue JSON::ParserError => e
58
37
  raise ServerError.new(response: response), "Invalid JSON response: #{e.message}"
59
38
  rescue *NETWORK_ERRORS => e
@@ -76,7 +55,7 @@ module ShopifyGraphql
76
55
 
77
56
  def handle_response(response, error = nil)
78
57
  case response.code
79
- when 200..400
58
+ when 200...400
80
59
  handle_graphql_errors(response)
81
60
  when 400
82
61
  raise BadRequest.new(response: response), error.message
@@ -85,6 +64,9 @@ module ShopifyGraphql
85
64
  when 402
86
65
  raise PaymentRequired.new(response: response), error.message
87
66
  when 403
67
+ if non_expiring_token_rejected?(response)
68
+ raise NonExpiringTokenRejected.new(response: response), error.message
69
+ end
88
70
  raise ForbiddenAccess.new(response: response), error.message
89
71
  when 404
90
72
  raise ResourceNotFound.new(response: response), error.message
@@ -113,11 +95,24 @@ module ShopifyGraphql
113
95
 
114
96
  def handle_graphql_errors(response)
115
97
  parsed_body = parsed_body(response)
116
- if parsed_body.errors.nil? || parsed_body.errors.empty?
117
- return parsed_body(response)
98
+ errors = parsed_body.errors
99
+ if errors.nil? || errors.empty?
100
+ return parsed_body
101
+ end
102
+
103
+ unless errors.is_a?(Array) && errors.first.is_a?(OpenStruct)
104
+ message = if errors.is_a?(OpenStruct)
105
+ errors.to_h.map { |key, value| "#{key}: #{value}" }.join(", ")
106
+ else
107
+ Array(errors).join(", ")
108
+ end
109
+ exception = ConnectionError.new(response: response)
110
+ exception.messages = [ message ]
111
+ attach_graphql_response(exception, parsed_body, errors: [ OpenStruct.new(message: message) ])
112
+ raise exception, generate_error_message(message: message)
118
113
  end
119
114
 
120
- error = parsed_body.errors.first
115
+ error = errors.first
121
116
  error_code = error.extensions&.code
122
117
  error_message = generate_error_message(
123
118
  message: error.message,
@@ -132,31 +127,54 @@ module ShopifyGraphql
132
127
  ServerError.new(response: response)
133
128
  when "SHOP_PENDING_TERMINATION"
134
129
  ShopPendingTermination.new(response: response)
130
+ when "SHOP_INACTIVE"
131
+ ShopInactive.new(response: response)
132
+ when "ACCESS_DENIED"
133
+ AccessDenied.new(response: response)
134
+ when "MAX_COST_EXCEEDED"
135
+ MaxCostExceeded.new(response: response)
135
136
  else
136
137
  ConnectionError.new(response: response)
137
138
  end
138
139
  exception.error_code = error_code
139
140
  exception.error_codes = [ error_code ]
140
141
  exception.messages = [ error.message ]
142
+ attach_graphql_response(exception, parsed_body)
141
143
  raise exception, error_message
142
144
  end
143
145
 
144
- def handle_user_errors(response)
145
- return response if response.userErrors.blank?
146
+ def non_expiring_token_rejected?(response)
147
+ body = response.body.is_a?(String) ? response.body : response.body.to_json
148
+ body.match?(NON_EXPIRING_TOKEN_MESSAGE)
149
+ end
150
+
151
+ def attach_graphql_response(exception, parsed_body, errors: parsed_body.errors)
152
+ exception.data = parsed_body.data
153
+ exception.errors = errors
154
+ exception.graphql_response = Response.new(parsed_body)
155
+ end
146
156
 
147
- error = response.userErrors.first
148
- errors = response.userErrors
157
+ # Pass the Response from execute as response: to attach its cost to UserError.
158
+ # Without it, the last Response returned by execute on this client is used.
159
+ def handle_user_errors(payload, response: nil)
160
+ return payload if payload.userErrors.blank?
161
+
162
+ error = payload.userErrors.first
163
+ errors = payload.userErrors
149
164
  error_message = generate_user_errors_message(
150
165
  messages: errors.map(&:message),
151
166
  codes: errors.map(&:code),
152
167
  fields: errors.map(&:field),
153
168
  )
154
169
 
155
- exception = UserError.new(response: response)
170
+ exception = UserError.new(response: payload)
156
171
  exception.error_code = error.code
157
172
  exception.error_codes = errors.map(&:code)
158
173
  exception.fields = errors.map(&:field)
159
174
  exception.messages = errors.map(&:message)
175
+ exception.data = payload
176
+ exception.errors = errors
177
+ exception.graphql_response = response || @last_response
160
178
  raise exception, error_message
161
179
  end
162
180
 
@@ -200,10 +218,5 @@ module ShopifyGraphql
200
218
  def client
201
219
  Client.new
202
220
  end
203
-
204
- def normalize_plan_display_name(plan_display_name)
205
- return if plan_display_name.blank?
206
- plan_display_name.parameterize(separator: "_")
207
- end
208
221
  end
209
222
  end
@@ -1,9 +1,14 @@
1
1
  module ShopifyGraphql
2
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
3
+ # All settings configure the deprecated webhooks
4
+ %i[webhooks webhook_jobs_namespace webhook_enabled_environments webhooks_manager_queue_name].each do |setting|
5
+ attr_reader setting
6
+
7
+ define_method(:"#{setting}=") do |value|
8
+ ShopifyGraphql::Deprecation.warn_webhooks_once("ShopifyGraphql::Configuration##{setting}=") if value.present?
9
+ instance_variable_set(:"@#{setting}", value)
10
+ end
11
+ end
7
12
 
8
13
  def initialize
9
14
  @webhooks_manager_queue_name = Rails.application.config.active_job.queue_name
@@ -12,7 +12,10 @@ module ShopifyGraphql
12
12
  private
13
13
 
14
14
  def deprecate_webhooks
15
- ShopifyGraphql.deprecator.warn("ShopifyGraphql webhooks are deprecated and will be removed in v3.0. Please use shopify_app gem for handling webhooks.")
15
+ ShopifyGraphql::Deprecation.warn_webhooks_once("ShopifyGraphql::WebhookVerification")
16
+ rescue ActiveSupport::DeprecationException => e
17
+ # Failing the request would make Shopify retry and eventually drop the subscription
18
+ logger&.warn(e.message)
16
19
  end
17
20
 
18
21
  def verify_request
@@ -0,0 +1,71 @@
1
+ require 'set'
2
+
3
+ module ShopifyGraphql
4
+ def self.deprecator
5
+ @deprecator ||= ActiveSupport::Deprecation.new("3.0", "ShopifyGraphql")
6
+ end
7
+
8
+ # Warnings for APIs removed in v3.0. Webhook jobs and managers run for every
9
+ # shop, so each API warns once per process instead of on every call.
10
+ module Deprecation # :nodoc:
11
+ PLAN_MESSAGE = "Please use plan_public_display_name, plan_shopify_plus and plan_partner_development from ShopifyGraphql::CurrentShop instead."
12
+ # Skipped in "called from" so the warning points at the app's code
13
+ GEM_PATHS = [File.expand_path("..", __dir__), File.expand_path("../../app", __dir__)].map { |path| "#{path}/" }.freeze
14
+
15
+ @warned = Set.new
16
+ @mutex = Mutex.new
17
+
18
+ class << self
19
+ def warn_once(key, message)
20
+ return unless @mutex.synchronize { @warned.add?(key) }
21
+
22
+ begin
23
+ ShopifyGraphql.deprecator.warn(message, app_callstack(caller_locations))
24
+ rescue Exception
25
+ # A deprecator configured to raise should raise on every call, not only the first
26
+ @mutex.synchronize { @warned.delete(key) }
27
+ raise
28
+ end
29
+ end
30
+
31
+ def warn_webhooks_once(api)
32
+ warn_once(api, "#{api} is part of ShopifyGraphql webhooks, which are deprecated and will be removed in v3.0. " \
33
+ "Please use shopify_app gem for handling webhooks or declare webhook subscriptions in shopify.app.toml.")
34
+ end
35
+
36
+ # Used by tests to see warnings again
37
+ def reset
38
+ @mutex.synchronize { @warned.clear }
39
+ end
40
+
41
+ private
42
+
43
+ # Rails < 7.1 raises NoMethodError on an empty callstack
44
+ def app_callstack(callstack)
45
+ app_frames = callstack.drop_while { |frame| frame.absolute_path.to_s.start_with?(*GEM_PATHS) }
46
+ app_frames.empty? ? callstack : app_frames
47
+ end
48
+ end
49
+
50
+ # Deprecated constant that warns once per process. It's a real constant, so
51
+ # unqualified lookups from code nested in ShopifyGraphql and defined? still work.
52
+ class ConstantProxy < ActiveSupport::Deprecation::DeprecatedConstantProxy # :nodoc:
53
+ private
54
+
55
+ def const_missing(name)
56
+ ShopifyGraphql::Deprecation.warn_once(@old_const, @message)
57
+ target.const_get(name)
58
+ end
59
+
60
+ def method_missing(called, *args, &block)
61
+ ShopifyGraphql::Deprecation.warn_once(@old_const, @message)
62
+ target.__send__(called, *args, &block)
63
+ end
64
+ end
65
+ end
66
+
67
+ def self.normalize_plan_display_name(plan_display_name)
68
+ Deprecation.warn_once("ShopifyGraphql.normalize_plan_display_name", "ShopifyGraphql.normalize_plan_display_name is deprecated and will be removed in v3.0. #{Deprecation::PLAN_MESSAGE}")
69
+ PlanNames.normalize_display_name(plan_display_name)
70
+ end
71
+ end
@@ -1,13 +1,9 @@
1
1
  module ShopifyGraphql
2
- def self.deprecator
3
- @deprecator ||= ActiveSupport::Deprecation.new("3.0", "ShopifyGraphql")
4
- end
5
-
6
2
  module RedactJobParams
7
3
  private
8
4
 
9
5
  def args_info(job)
10
- log_disabled_classes = %[
6
+ log_disabled_classes = %w[
11
7
  ShopifyGraphql::CreateWebhooksJob
12
8
  ShopifyGraphql::DestroyWebhooksJob
13
9
  ShopifyGraphql::UpdateWebhooksJob
@@ -21,6 +17,10 @@ module ShopifyGraphql
21
17
  engine_name 'shopify_graphql'
22
18
  isolate_namespace ShopifyGraphql
23
19
 
20
+ initializer "shopify_graphql.deprecator" do |app|
21
+ app.deprecators[:shopify_graphql] = ShopifyGraphql.deprecator if app.respond_to?(:deprecators)
22
+ end
23
+
24
24
  initializer "shopify_graphql.redact_job_params" do
25
25
  ActiveSupport.on_load(:active_job) do
26
26
  if ActiveJob::Base.respond_to?(:log_arguments?)
@@ -1,6 +1,37 @@
1
1
  module ShopifyGraphql
2
2
  class ConnectionError < ShopifyAPI::Errors::HttpResponseError
3
3
  attr_accessor :error_code, :error_codes, :fields, :messages
4
+ # Partial data from the GraphQL response. For UserError, the mutation payload.
5
+ attr_accessor :data
6
+ # GraphQL errors from the response, or an empty Array when there are none.
7
+ # For UserError, the payload's userErrors.
8
+ attr_writer :errors
9
+ # ShopifyGraphql::Response the error came from, used for cost information
10
+ attr_accessor :graphql_response
11
+
12
+ def errors
13
+ @errors || []
14
+ end
15
+
16
+ def query_cost
17
+ graphql_response&.query_cost
18
+ end
19
+
20
+ def requested_query_cost
21
+ graphql_response&.requested_query_cost
22
+ end
23
+
24
+ def points_left
25
+ graphql_response&.points_left
26
+ end
27
+
28
+ def points_limit
29
+ graphql_response&.points_limit
30
+ end
31
+
32
+ def points_restore_rate
33
+ graphql_response&.points_restore_rate
34
+ end
4
35
 
5
36
  def initialize(response: nil)
6
37
  unless response
@@ -21,6 +52,33 @@ module ShopifyGraphql
21
52
  end
22
53
  end
23
54
 
55
+ # Marks errors that mean the shop can't be used right now: the app was
56
+ # uninstalled or the token revoked, or the shop is frozen, locked, inactive or
57
+ # pending termination. Use it to stop work for the shop:
58
+ #
59
+ # rescue ShopifyGraphql::ShopUnavailable
60
+ # discard_on ShopifyGraphql::ShopUnavailable
61
+ #
62
+ # Members:
63
+ # - UnauthorizedAccess (401): app uninstalled or access token revoked. Shopify
64
+ # also returns 401 for an expired access token, so with expiring offline
65
+ # tokens a 401 can mean the token wasn't refreshed. Refresh it before
66
+ # treating the shop as gone.
67
+ # - PaymentRequired (402): shop is frozen
68
+ # - ShopLocked (423): shop is locked
69
+ # - ShopPendingTermination (SHOP_PENDING_TERMINATION): shop is under review
70
+ # - ShopInactive (SHOP_INACTIVE): shop is not active
71
+ #
72
+ # Not included:
73
+ # - ForbiddenAccess (403) and NonExpiringTokenRejected. On GraphQL, a 403 means
74
+ # the shop was marked as fraudulent, and from January 1, 2027 it's also
75
+ # returned for a non-expiring offline access token. Treating every 403 as
76
+ # "shop gone" could mark every shop uninstalled once those tokens stop working.
77
+ # - ResourceNotFound (404). It's also raised for null nodes.
78
+ # - AccessDenied (ACCESS_DENIED). It means a missing access scope.
79
+ module ShopUnavailable
80
+ end
81
+
24
82
  # 4xx Client Error
25
83
  class ClientError < ConnectionError
26
84
  end
@@ -31,16 +89,23 @@ module ShopifyGraphql
31
89
 
32
90
  # 401 Unauthorized
33
91
  class UnauthorizedAccess < ClientError # :nodoc:
92
+ include ShopUnavailable
34
93
  end
35
94
 
36
95
  # 402 Payment Required
37
96
  class PaymentRequired < ClientError # :nodoc:
97
+ include ShopUnavailable
38
98
  end
39
99
 
40
100
  # 403 Forbidden
41
101
  class ForbiddenAccess < ClientError # :nodoc:
42
102
  end
43
103
 
104
+ # 403 Forbidden for a public app's non-expiring offline access token, returned
105
+ # from January 1, 2027. Exchange it for an expiring token.
106
+ class NonExpiringTokenRejected < ForbiddenAccess # :nodoc:
107
+ end
108
+
44
109
  # 404 Not Found
45
110
  class ResourceNotFound < ClientError # :nodoc:
46
111
  end
@@ -61,12 +126,34 @@ module ShopifyGraphql
61
126
  class PreconditionFailed < ClientError # :nodoc:
62
127
  end
63
128
 
129
+ # 422 Unprocessable Entity
130
+ class ResourceInvalid < ClientError # :nodoc:
131
+ end
132
+
64
133
  # 423 Locked
65
134
  class ShopLocked < ClientError # :nodoc:
135
+ include ShopUnavailable
66
136
  end
67
137
 
68
138
  # GraphQL SHOP_PENDING_TERMINATION error code (shop is under review)
69
139
  class ShopPendingTermination < ClientError # :nodoc:
140
+ include ShopUnavailable
141
+ end
142
+
143
+ # GraphQL SHOP_INACTIVE error code (shop is not active, e.g. after repeated
144
+ # rate limit overages or due to fraud risk)
145
+ class ShopInactive < ClientError # :nodoc:
146
+ include ShopUnavailable
147
+ end
148
+
149
+ # GraphQL ACCESS_DENIED error code (missing access scope). Returned with
150
+ # HTTP 200, not 403.
151
+ class AccessDenied < ClientError # :nodoc:
152
+ end
153
+
154
+ # GraphQL MAX_COST_EXCEEDED error code (query is over the single query cost
155
+ # limit). Retrying doesn't help: reduce the query or use a bulk operation.
156
+ class MaxCostExceeded < ClientError # :nodoc:
70
157
  end
71
158
 
72
159
  # 429 Too Many Requests
@@ -5,6 +5,7 @@ module ShopifyGraphql
5
5
  end
6
6
 
7
7
  def perform(shop_domain:, shop_token:)
8
+ ShopifyGraphql::Deprecation.warn_webhooks_once("ShopifyGraphql::CreateWebhooksJob")
8
9
  webhooks = ShopifyGraphql.configuration.webhooks
9
10
 
10
11
  ShopifyAPI::Auth::Session.temp(shop: shop_domain, access_token: shop_token) do
@@ -5,6 +5,7 @@ module ShopifyGraphql
5
5
  end
6
6
 
7
7
  def perform(shop_domain:, shop_token:)
8
+ ShopifyGraphql::Deprecation.warn_webhooks_once("ShopifyGraphql::DestroyWebhooksJob")
8
9
  webhooks = ShopifyGraphql.configuration.webhooks
9
10
 
10
11
  ShopifyAPI::Auth::Session.temp(shop: shop_domain, access_token: shop_token) do
@@ -7,6 +7,7 @@ module ShopifyGraphql
7
7
  retry_on ShopifyGraphql::TooManyRequests
8
8
 
9
9
  def perform(shop_domain:, shop_token:)
10
+ ShopifyGraphql::Deprecation.warn_webhooks_once("ShopifyGraphql::UpdateWebhooksJob")
10
11
  webhooks = ShopifyGraphql.configuration.webhooks
11
12
 
12
13
  ShopifyAPI::Auth::Session.temp(shop: shop_domain, access_token: shop_token) do
@@ -2,6 +2,7 @@ module ShopifyGraphql
2
2
  class WebhooksManager
3
3
  class << self
4
4
  def queue_create(shop_domain, shop_token)
5
+ ShopifyGraphql::Deprecation.warn_webhooks_once("ShopifyGraphql::WebhooksManager.queue_create")
5
6
  ShopifyGraphql::CreateWebhooksJob.perform_later(
6
7
  shop_domain: shop_domain,
7
8
  shop_token: shop_token,
@@ -9,6 +10,7 @@ module ShopifyGraphql
9
10
  end
10
11
 
11
12
  def queue_destroy(shop_domain, shop_token)
13
+ ShopifyGraphql::Deprecation.warn_webhooks_once("ShopifyGraphql::WebhooksManager.queue_destroy")
12
14
  ShopifyGraphql::DestroyWebhooksJob.perform_later(
13
15
  shop_domain: shop_domain,
14
16
  shop_token: shop_token,
@@ -16,6 +18,7 @@ module ShopifyGraphql
16
18
  end
17
19
 
18
20
  def queue_update(shop_domain, shop_token)
21
+ ShopifyGraphql::Deprecation.warn_webhooks_once("ShopifyGraphql::WebhooksManager.queue_update")
19
22
  ShopifyGraphql::UpdateWebhooksJob.perform_later(
20
23
  shop_domain: shop_domain,
21
24
  shop_token: shop_token,
@@ -26,6 +29,7 @@ module ShopifyGraphql
26
29
  attr_reader :required_webhooks
27
30
 
28
31
  def initialize(webhooks)
32
+ ShopifyGraphql::Deprecation.warn_webhooks_once("ShopifyGraphql::WebhooksManager")
29
33
  @required_webhooks = webhooks
30
34
  end
31
35
 
@@ -0,0 +1,44 @@
1
+ module ShopifyGraphql
2
+ # Maps plan.displayName to the legacy plan_name for CurrentShop. The public
3
+ # ShopifyGraphql::PLAN_TO_DISPLAY_NAME and DISPLAY_NAME_TO_PLAN below are deprecated.
4
+ module PlanNames # :nodoc:
5
+ # Mapping from deprecated plan_name to plan_display_name
6
+ PLAN_TO_DISPLAY_NAME = {
7
+ "trial" => "trial",
8
+ "frozen" => "frozen",
9
+ "fraudulent" => "cancelled",
10
+ "shopify_alumni" => "shopify_alumni",
11
+ "affiliate" => "development",
12
+ "basic" => "basic",
13
+ "professional" => "shopify",
14
+ "npo_full" => "npo_full",
15
+ "shopify_plus" => "shopify_plus",
16
+ "staff" => "staff",
17
+ "unlimited" => "advanced",
18
+ "retail" => "retail",
19
+ "cancelled" => "cancelled",
20
+ "dormant" => "pause_and_build",
21
+ "starter_2022" => "shopify_starter",
22
+ "plus_partner_sandbox" => "shopify_plus_partner_sandbox",
23
+ "paid_trial" => "extended_trial",
24
+ "partner_test" => "developer_preview",
25
+ "open_learning" => "open_learning",
26
+ "staff_business" => "staff_business"
27
+ }
28
+ DISPLAY_NAME_TO_PLAN = PLAN_TO_DISPLAY_NAME.invert
29
+
30
+ def self.normalize_display_name(plan_display_name)
31
+ return if plan_display_name.blank?
32
+ plan_display_name.parameterize(separator: "_")
33
+ end
34
+ end
35
+
36
+ PLAN_TO_DISPLAY_NAME = Deprecation::ConstantProxy.new(
37
+ "ShopifyGraphql::PLAN_TO_DISPLAY_NAME", "ShopifyGraphql::PlanNames::PLAN_TO_DISPLAY_NAME", deprecator,
38
+ message: "ShopifyGraphql::PLAN_TO_DISPLAY_NAME is deprecated and will be removed in v3.0. #{Deprecation::PLAN_MESSAGE}"
39
+ )
40
+ DISPLAY_NAME_TO_PLAN = Deprecation::ConstantProxy.new(
41
+ "ShopifyGraphql::DISPLAY_NAME_TO_PLAN", "ShopifyGraphql::PlanNames::DISPLAY_NAME_TO_PLAN", deprecator,
42
+ message: "ShopifyGraphql::DISPLAY_NAME_TO_PLAN is deprecated and will be removed in v3.0. #{Deprecation::PLAN_MESSAGE}"
43
+ )
44
+ end
@@ -2,6 +2,17 @@ module ShopifyGraphql
2
2
  module Resource
3
3
  extend ActiveSupport::Concern
4
4
 
5
+ # The gem's own Shop and Webhook warn when they're used instead
6
+ INTERNAL_RESOURCES = %w[ShopifyGraphql::Shop ShopifyGraphql::Webhook].freeze
7
+ private_constant :INTERNAL_RESOURCES
8
+
9
+ included do
10
+ unless INTERNAL_RESOURCES.include?(name)
11
+ ShopifyGraphql::Deprecation.warn_once("ShopifyGraphql::Resource", "ShopifyGraphql::Resource is deprecated and will be removed in v3.0. " \
12
+ "Please include ShopifyGraphql::Query or ShopifyGraphql::Mutation in your own classes instead.")
13
+ end
14
+ end
15
+
5
16
  class_methods do
6
17
  extend Forwardable
7
18
  def_delegators :client, :execute, :handle_user_errors
@@ -4,6 +4,8 @@ module ShopifyGraphql
4
4
 
5
5
  class << self
6
6
  def current
7
+ ShopifyGraphql::Deprecation.warn_once("ShopifyGraphql::Shop.current", "ShopifyGraphql::Shop.current is deprecated and will be removed in v3.0. " \
8
+ "Please use ShopifyGraphql::CurrentShop or your own ShopifyGraphql::Query class instead.")
7
9
  execute <<~GRAPHQL
8
10
  query {
9
11
  shop {
@@ -48,6 +48,7 @@ module ShopifyGraphql
48
48
 
49
49
  class << self
50
50
  def all
51
+ ShopifyGraphql::Deprecation.warn_webhooks_once("ShopifyGraphql::Webhook")
51
52
  response = execute(ALL_WEBHOOKS_QUERY)
52
53
  response.data.webhookSubscriptions.edges.map do |edge|
53
54
  edge.node
@@ -55,6 +56,7 @@ module ShopifyGraphql
55
56
  end
56
57
 
57
58
  def create(topic:, address:, include_fields:)
59
+ ShopifyGraphql::Deprecation.warn_webhooks_once("ShopifyGraphql::Webhook")
58
60
  response = execute(CREATE_WEBHOOK_MUTATION,
59
61
  topic: topic,
60
62
  webhookSubscription: {
@@ -68,6 +70,7 @@ module ShopifyGraphql
68
70
  end
69
71
 
70
72
  def delete(id)
73
+ ShopifyGraphql::Deprecation.warn_webhooks_once("ShopifyGraphql::Webhook")
71
74
  response = execute(DELETE_WEBHOOK_MUTATION, id: id)
72
75
  response = response.data.webhookSubscriptionDelete
73
76
  handle_user_errors(response)
@@ -26,7 +26,13 @@ module ShopifyGraphql
26
26
  extensions&.cost&.actualQueryCost
27
27
  end
28
28
 
29
+ def requested_query_cost
30
+ extensions&.cost&.requestedQueryCost
31
+ end
32
+
29
33
  def points_maxed?(threshold: 0)
34
+ return false if points_left.nil?
35
+
30
36
  points_left < threshold
31
37
  end
32
38
  end
@@ -1,3 +1,3 @@
1
1
  module ShopifyGraphql
2
- VERSION = "2.5.2"
2
+ VERSION = "2.6.0"
3
3
  end
@@ -1,6 +1,9 @@
1
+ # OpenStruct holds parsed GraphQL responses; shopify_api uses it without requiring it
2
+ require 'ostruct'
1
3
  require 'shopify_api'
2
4
  require 'active_support'
3
5
 
6
+ require 'shopify_graphql/deprecation'
4
7
  require 'shopify_graphql/client'
5
8
  require 'shopify_graphql/configuration'
6
9
  if defined?(Rails)
@@ -8,6 +11,7 @@ if defined?(Rails)
8
11
  end
9
12
  require 'shopify_graphql/exceptions'
10
13
  require 'shopify_graphql/mutation'
14
+ require 'shopify_graphql/plan_names'
11
15
  require 'shopify_graphql/query'
12
16
  require 'shopify_graphql/resource'
13
17
  require 'shopify_graphql/response'
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shopify_graphql
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.5.2
4
+ version: 2.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kirill Platonov
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-09-17 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rails
@@ -66,7 +65,20 @@ dependencies:
66
65
  - - ">="
67
66
  - !ruby/object:Gem::Version
68
67
  version: '19.0'
69
- description:
68
+ - !ruby/object:Gem::Dependency
69
+ name: ostruct
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
70
82
  email:
71
83
  - mail@kirillplatonov.com
72
84
  executables: []
@@ -94,6 +106,7 @@ files:
94
106
  - lib/shopify_graphql/configuration.rb
95
107
  - lib/shopify_graphql/controller_concerns/payload_verification.rb
96
108
  - lib/shopify_graphql/controller_concerns/webhook_verification.rb
109
+ - lib/shopify_graphql/deprecation.rb
97
110
  - lib/shopify_graphql/engine.rb
98
111
  - lib/shopify_graphql/exceptions.rb
99
112
  - lib/shopify_graphql/jobs/create_webhooks_job.rb
@@ -101,6 +114,7 @@ files:
101
114
  - lib/shopify_graphql/jobs/update_webhooks_job.rb
102
115
  - lib/shopify_graphql/managers/webhooks_manager.rb
103
116
  - lib/shopify_graphql/mutation.rb
117
+ - lib/shopify_graphql/plan_names.rb
104
118
  - lib/shopify_graphql/query.rb
105
119
  - lib/shopify_graphql/resource.rb
106
120
  - lib/shopify_graphql/resources/shop.rb
@@ -113,7 +127,6 @@ licenses:
113
127
  - MIT
114
128
  metadata:
115
129
  allowed_push_host: https://rubygems.org
116
- post_install_message:
117
130
  rdoc_options: []
118
131
  require_paths:
119
132
  - lib
@@ -128,8 +141,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
128
141
  - !ruby/object:Gem::Version
129
142
  version: '0'
130
143
  requirements: []
131
- rubygems_version: 3.5.11
132
- signing_key:
144
+ rubygems_version: 3.6.9
133
145
  specification_version: 4
134
146
  summary: Less painful way to work with Shopify Graphql API in Ruby.
135
147
  test_files: []