shopify_graphql 2.5.1 → 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 +4 -4
- data/README.md +117 -6
- data/app/graphql/shopify_graphql/create_bulk_query.rb +23 -2
- data/app/graphql/shopify_graphql/current_shop.rb +48 -49
- data/app/graphql/shopify_graphql/delete_private_metafield.rb +3 -0
- data/app/graphql/shopify_graphql/get_bulk_operation.rb +7 -0
- data/app/graphql/shopify_graphql/upsert_private_metafield.rb +3 -0
- data/lib/shopify_graphql/client.rb +69 -42
- data/lib/shopify_graphql/configuration.rb +9 -4
- data/lib/shopify_graphql/controller_concerns/webhook_verification.rb +4 -1
- data/lib/shopify_graphql/deprecation.rb +71 -0
- data/lib/shopify_graphql/engine.rb +5 -5
- data/lib/shopify_graphql/exceptions.rb +87 -0
- data/lib/shopify_graphql/jobs/create_webhooks_job.rb +1 -0
- data/lib/shopify_graphql/jobs/destroy_webhooks_job.rb +1 -0
- data/lib/shopify_graphql/jobs/update_webhooks_job.rb +1 -0
- data/lib/shopify_graphql/managers/webhooks_manager.rb +4 -0
- data/lib/shopify_graphql/plan_names.rb +44 -0
- data/lib/shopify_graphql/resource.rb +11 -0
- data/lib/shopify_graphql/resources/shop.rb +2 -0
- data/lib/shopify_graphql/resources/webhook.rb +3 -0
- data/lib/shopify_graphql/response.rb +6 -0
- data/lib/shopify_graphql/version.rb +1 -1
- data/lib/shopify_graphql.rb +4 -0
- metadata +19 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bcbd21fda0033f2ada1d0f6c86e19d1bc46bb8d5a787cf868c7f19d877842d07
|
|
4
|
+
data.tar.gz: 932aaf0b971c2b463eff279dbe9c00512e18b8c26863aa21f71eec6b7cb9b68f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
createdAt
|
|
17
|
+
updatedAt
|
|
18
|
+
shopOwnerName
|
|
19
19
|
currencyCode
|
|
20
|
-
|
|
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
|
-
|
|
54
|
+
marketingSmsConsentEnabledAtCheckout
|
|
51
55
|
resourceLimits {
|
|
52
56
|
maxProductOptions
|
|
53
57
|
maxProductVariants
|
|
54
58
|
}
|
|
55
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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 =
|
|
107
|
-
plan_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.
|
|
120
|
-
country: data.shop.
|
|
121
|
-
country_code: data.shop.
|
|
122
|
-
province: data.shop.
|
|
123
|
-
province_code: data.shop.
|
|
124
|
-
city: data.shop.
|
|
125
|
-
address1: data.shop.
|
|
126
|
-
address2: data.shop.
|
|
127
|
-
zip: data.shop.
|
|
128
|
-
latitude: data.shop.
|
|
129
|
-
longitude: data.shop.
|
|
130
|
-
phone: data.shop.
|
|
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:
|
|
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,30 +1,22 @@
|
|
|
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
|
|
3
|
+
NETWORK_ERRORS = [
|
|
4
|
+
Errno::ECONNRESET,
|
|
5
|
+
Errno::ECONNABORTED,
|
|
6
|
+
Errno::ECONNREFUSED,
|
|
7
|
+
Errno::EPIPE,
|
|
8
|
+
Errno::ENETUNREACH,
|
|
9
|
+
Errno::EHOSTUNREACH,
|
|
10
|
+
Errno::ETIMEDOUT,
|
|
11
|
+
Net::ReadTimeout,
|
|
12
|
+
Net::OpenTimeout,
|
|
13
|
+
OpenSSL::SSL::SSLError,
|
|
14
|
+
EOFError
|
|
15
|
+
].freeze
|
|
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
|
+
|
|
28
20
|
def client
|
|
29
21
|
session = ShopifyAPI::Context.active_session
|
|
30
22
|
if @client.nil? || @access_token != session&.access_token || @shop != session&.shop
|
|
@@ -36,13 +28,14 @@ module ShopifyGraphql
|
|
|
36
28
|
end
|
|
37
29
|
|
|
38
30
|
def execute(query, headers: nil, **variables)
|
|
31
|
+
@last_response = nil
|
|
39
32
|
response = client.query(query: query, variables: variables, headers: headers)
|
|
40
|
-
Response.new(handle_response(response))
|
|
33
|
+
@last_response = Response.new(handle_response(response))
|
|
41
34
|
rescue ShopifyAPI::Errors::HttpResponseError => e
|
|
42
|
-
Response.new(handle_response(e.response, e))
|
|
35
|
+
@last_response = Response.new(handle_response(e.response, e))
|
|
43
36
|
rescue JSON::ParserError => e
|
|
44
37
|
raise ServerError.new(response: response), "Invalid JSON response: #{e.message}"
|
|
45
|
-
rescue
|
|
38
|
+
rescue *NETWORK_ERRORS => e
|
|
46
39
|
raise ServerError.new(response: response), "Network error: #{e.message}"
|
|
47
40
|
rescue => e
|
|
48
41
|
if (defined?(Socket::ResolutionError) and e.is_a?(Socket::ResolutionError))
|
|
@@ -62,7 +55,7 @@ module ShopifyGraphql
|
|
|
62
55
|
|
|
63
56
|
def handle_response(response, error = nil)
|
|
64
57
|
case response.code
|
|
65
|
-
when 200
|
|
58
|
+
when 200...400
|
|
66
59
|
handle_graphql_errors(response)
|
|
67
60
|
when 400
|
|
68
61
|
raise BadRequest.new(response: response), error.message
|
|
@@ -71,6 +64,9 @@ module ShopifyGraphql
|
|
|
71
64
|
when 402
|
|
72
65
|
raise PaymentRequired.new(response: response), error.message
|
|
73
66
|
when 403
|
|
67
|
+
if non_expiring_token_rejected?(response)
|
|
68
|
+
raise NonExpiringTokenRejected.new(response: response), error.message
|
|
69
|
+
end
|
|
74
70
|
raise ForbiddenAccess.new(response: response), error.message
|
|
75
71
|
when 404
|
|
76
72
|
raise ResourceNotFound.new(response: response), error.message
|
|
@@ -99,11 +95,24 @@ module ShopifyGraphql
|
|
|
99
95
|
|
|
100
96
|
def handle_graphql_errors(response)
|
|
101
97
|
parsed_body = parsed_body(response)
|
|
102
|
-
|
|
103
|
-
|
|
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)
|
|
104
113
|
end
|
|
105
114
|
|
|
106
|
-
error =
|
|
115
|
+
error = errors.first
|
|
107
116
|
error_code = error.extensions&.code
|
|
108
117
|
error_message = generate_error_message(
|
|
109
118
|
message: error.message,
|
|
@@ -118,31 +127,54 @@ module ShopifyGraphql
|
|
|
118
127
|
ServerError.new(response: response)
|
|
119
128
|
when "SHOP_PENDING_TERMINATION"
|
|
120
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)
|
|
121
136
|
else
|
|
122
137
|
ConnectionError.new(response: response)
|
|
123
138
|
end
|
|
124
139
|
exception.error_code = error_code
|
|
125
140
|
exception.error_codes = [ error_code ]
|
|
126
141
|
exception.messages = [ error.message ]
|
|
142
|
+
attach_graphql_response(exception, parsed_body)
|
|
127
143
|
raise exception, error_message
|
|
128
144
|
end
|
|
129
145
|
|
|
130
|
-
def
|
|
131
|
-
|
|
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
|
|
132
156
|
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
135
164
|
error_message = generate_user_errors_message(
|
|
136
165
|
messages: errors.map(&:message),
|
|
137
166
|
codes: errors.map(&:code),
|
|
138
167
|
fields: errors.map(&:field),
|
|
139
168
|
)
|
|
140
169
|
|
|
141
|
-
exception = UserError.new(response:
|
|
170
|
+
exception = UserError.new(response: payload)
|
|
142
171
|
exception.error_code = error.code
|
|
143
172
|
exception.error_codes = errors.map(&:code)
|
|
144
173
|
exception.fields = errors.map(&:field)
|
|
145
174
|
exception.messages = errors.map(&:message)
|
|
175
|
+
exception.data = payload
|
|
176
|
+
exception.errors = errors
|
|
177
|
+
exception.graphql_response = response || @last_response
|
|
146
178
|
raise exception, error_message
|
|
147
179
|
end
|
|
148
180
|
|
|
@@ -186,10 +218,5 @@ module ShopifyGraphql
|
|
|
186
218
|
def client
|
|
187
219
|
Client.new
|
|
188
220
|
end
|
|
189
|
-
|
|
190
|
-
def normalize_plan_display_name(plan_display_name)
|
|
191
|
-
return if plan_display_name.blank?
|
|
192
|
-
plan_display_name.parameterize(separator: "_")
|
|
193
|
-
end
|
|
194
221
|
end
|
|
195
222
|
end
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
module ShopifyGraphql
|
|
2
2
|
class Configuration
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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.
|
|
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
|
data/lib/shopify_graphql.rb
CHANGED
|
@@ -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.
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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: []
|