shopify_api 14.11.1 → 16.0.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/.github/workflows/api_update_reminder.yml +2 -2
- data/.github/workflows/api_update_reminder_on_release.yml +2 -2
- data/.github/workflows/build.yml +3 -4
- data/.github/workflows/close-waiting-for-response-issues.yml +1 -1
- data/.github/workflows/remove-labels-on-activity.yml +1 -1
- data/BREAKING_CHANGES_FOR_V15.md +98 -3
- data/BREAKING_CHANGES_FOR_V16.md +76 -0
- data/CHANGELOG.md +15 -1
- data/Gemfile.lock +1 -1
- data/RELEASING.md +108 -17
- data/REST_RESOURCES.md +161 -0
- data/docs/getting_started.md +2 -1
- data/docs/usage/oauth.md +129 -0
- data/docs/usage/webhooks.md +0 -17
- data/lib/shopify_api/admin_versions.rb +1 -5
- data/lib/shopify_api/auth/oauth/access_token_response.rb +5 -1
- data/lib/shopify_api/auth/oauth.rb +7 -2
- data/lib/shopify_api/auth/refresh_token.rb +57 -0
- data/lib/shopify_api/auth/session.rb +36 -19
- data/lib/shopify_api/auth/token_exchange.rb +50 -0
- data/lib/shopify_api/clients/graphql/storefront.rb +5 -11
- data/lib/shopify_api/clients/http_client.rb +2 -0
- data/lib/shopify_api/context.rb +12 -5
- data/lib/shopify_api/rest/resources/2025_10/abandoned_checkout.rb +194 -0
- data/lib/shopify_api/rest/resources/2025_10/access_scope.rb +62 -0
- data/lib/shopify_api/rest/resources/2025_10/apple_pay_certificate.rb +109 -0
- data/lib/shopify_api/rest/resources/2025_10/application_charge.rb +113 -0
- data/lib/shopify_api/rest/resources/2025_10/application_credit.rb +95 -0
- data/lib/shopify_api/rest/resources/2025_10/article.rb +269 -0
- data/lib/shopify_api/rest/resources/2025_10/asset.rb +122 -0
- data/lib/shopify_api/rest/resources/2025_10/assigned_fulfillment_order.rb +92 -0
- data/lib/shopify_api/rest/resources/2025_10/balance.rb +58 -0
- data/lib/shopify_api/rest/resources/2025_10/blog.rb +166 -0
- data/lib/shopify_api/rest/resources/2025_10/cancellation_request.rb +87 -0
- data/lib/shopify_api/rest/resources/2025_10/carrier_service.rb +120 -0
- data/lib/shopify_api/rest/resources/2025_10/checkout.rb +213 -0
- data/lib/shopify_api/rest/resources/2025_10/collect.rb +146 -0
- data/lib/shopify_api/rest/resources/2025_10/collection.rb +114 -0
- data/lib/shopify_api/rest/resources/2025_10/collection_listing.rb +159 -0
- data/lib/shopify_api/rest/resources/2025_10/comment.rb +287 -0
- data/lib/shopify_api/rest/resources/2025_10/country.rb +141 -0
- data/lib/shopify_api/rest/resources/2025_10/currency.rb +61 -0
- data/lib/shopify_api/rest/resources/2025_10/custom_collection.rb +191 -0
- data/lib/shopify_api/rest/resources/2025_10/customer.rb +328 -0
- data/lib/shopify_api/rest/resources/2025_10/deprecated_api_call.rb +61 -0
- data/lib/shopify_api/rest/resources/2025_10/discount_code.rb +226 -0
- data/lib/shopify_api/rest/resources/2025_10/dispute.rb +115 -0
- data/lib/shopify_api/rest/resources/2025_10/dispute_evidence.rb +121 -0
- data/lib/shopify_api/rest/resources/2025_10/dispute_file_upload.rb +85 -0
- data/lib/shopify_api/rest/resources/2025_10/draft_order.rb +279 -0
- data/lib/shopify_api/rest/resources/2025_10/event.rb +152 -0
- data/lib/shopify_api/rest/resources/2025_10/fulfillment.rb +235 -0
- data/lib/shopify_api/rest/resources/2025_10/fulfillment_event.rb +167 -0
- data/lib/shopify_api/rest/resources/2025_10/fulfillment_order.rb +326 -0
- data/lib/shopify_api/rest/resources/2025_10/fulfillment_request.rb +116 -0
- data/lib/shopify_api/rest/resources/2025_10/fulfillment_service.rb +134 -0
- data/lib/shopify_api/rest/resources/2025_10/gift_card.rb +222 -0
- data/lib/shopify_api/rest/resources/2025_10/gift_card_adjustment.rb +122 -0
- data/lib/shopify_api/rest/resources/2025_10/image.rb +161 -0
- data/lib/shopify_api/rest/resources/2025_10/inventory_item.rb +112 -0
- data/lib/shopify_api/rest/resources/2025_10/inventory_level.rb +183 -0
- data/lib/shopify_api/rest/resources/2025_10/location.rb +171 -0
- data/lib/shopify_api/rest/resources/2025_10/locations_for_move.rb +60 -0
- data/lib/shopify_api/rest/resources/2025_10/marketing_event.rb +213 -0
- data/lib/shopify_api/rest/resources/2025_10/metafield.rb +348 -0
- data/lib/shopify_api/rest/resources/2025_10/mobile_platform_application.rb +120 -0
- data/lib/shopify_api/rest/resources/2025_10/order.rb +503 -0
- data/lib/shopify_api/rest/resources/2025_10/order_risk.rb +148 -0
- data/lib/shopify_api/rest/resources/2025_10/page.rb +198 -0
- data/lib/shopify_api/rest/resources/2025_10/payment.rb +98 -0
- data/lib/shopify_api/rest/resources/2025_10/payment_gateway.rb +147 -0
- data/lib/shopify_api/rest/resources/2025_10/payment_transaction.rb +117 -0
- data/lib/shopify_api/rest/resources/2025_10/payout.rb +101 -0
- data/lib/shopify_api/rest/resources/2025_10/policy.rb +73 -0
- data/lib/shopify_api/rest/resources/2025_10/price_rule.rb +227 -0
- data/lib/shopify_api/rest/resources/2025_10/product.rb +227 -0
- data/lib/shopify_api/rest/resources/2025_10/product_listing.rb +200 -0
- data/lib/shopify_api/rest/resources/2025_10/product_resource_feedback.rb +92 -0
- data/lib/shopify_api/rest/resources/2025_10/province.rb +136 -0
- data/lib/shopify_api/rest/resources/2025_10/recurring_application_charge.rb +184 -0
- data/lib/shopify_api/rest/resources/2025_10/redirect.rb +143 -0
- data/lib/shopify_api/rest/resources/2025_10/refund.rb +158 -0
- data/lib/shopify_api/rest/resources/2025_10/resource_feedback.rb +77 -0
- data/lib/shopify_api/rest/resources/2025_10/script_tag.rb +159 -0
- data/lib/shopify_api/rest/resources/2025_10/shipping_zone.rb +87 -0
- data/lib/shopify_api/rest/resources/2025_10/shop.rb +231 -0
- data/lib/shopify_api/rest/resources/2025_10/smart_collection.rb +220 -0
- data/lib/shopify_api/rest/resources/2025_10/storefront_access_token.rb +91 -0
- data/lib/shopify_api/rest/resources/2025_10/tender_transaction.rb +97 -0
- data/lib/shopify_api/rest/resources/2025_10/theme.rb +127 -0
- data/lib/shopify_api/rest/resources/2025_10/transaction.rb +194 -0
- data/lib/shopify_api/rest/resources/2025_10/usage_charge.rb +106 -0
- data/lib/shopify_api/rest/resources/2025_10/user.rb +142 -0
- data/lib/shopify_api/rest/resources/2025_10/variant.rb +212 -0
- data/lib/shopify_api/rest/resources/2025_10/webhook.rb +173 -0
- data/lib/shopify_api/version.rb +1 -1
- data/lib/shopify_api/webhooks/registration.rb +3 -3
- data/lib/shopify_api/webhooks/registry.rb +3 -13
- data/lib/shopify_api/webhooks/{handler.rb → webhook_handler.rb} +0 -12
- data/lib/shopify_api.rb +1 -1
- data/shopify_api.gemspec +1 -1
- metadata +79 -4
data/docs/usage/oauth.md
CHANGED
|
@@ -13,6 +13,9 @@ For more information on authenticating a Shopify app please see the [Types of Au
|
|
|
13
13
|
- [Token Exchange](#token-exchange)
|
|
14
14
|
- [Authorization Code Grant](#authorization-code-grant)
|
|
15
15
|
- [Client Credentials Grant](#client-credentials-grant)
|
|
16
|
+
- [Expiring Offline Access Tokens](#expiring-offline-access-tokens)
|
|
17
|
+
- [Refreshing Access Tokens](#refreshing-access-tokens)
|
|
18
|
+
- [Migrating Non-Expiring Tokens to Expiring Tokens](#migrating-non-expiring-tokens-to-expiring-tokens)
|
|
16
19
|
- [Using OAuth Session to make authenticated API calls](#using-oauth-session-to-make-authenticated-api-calls)
|
|
17
20
|
|
|
18
21
|
## Session Persistence
|
|
@@ -305,6 +308,132 @@ end
|
|
|
305
308
|
|
|
306
309
|
```
|
|
307
310
|
|
|
311
|
+
## Expiring Offline Access Tokens
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
To start requesting expiring offline access tokens, set the `expiring_offline_access_tokens` parameter to `true` when setting up the Shopify context:
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
ShopifyAPI::Context.setup(
|
|
318
|
+
api_key: <SHOPIFY_API_KEY>,
|
|
319
|
+
api_secret_key: <SHOPIFY_API_SECRET>,
|
|
320
|
+
api_version: <SHOPIFY_API_VERSION>,
|
|
321
|
+
scope: <SHOPIFY_API_SCOPES>,
|
|
322
|
+
expiring_offline_access_tokens: true, # Enable expiring offline access tokens
|
|
323
|
+
...
|
|
324
|
+
)
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
When enabled:
|
|
328
|
+
- **Authorization Code Grant**: The OAuth flow will request expiring offline access tokens by sending `expiring: 1` parameter
|
|
329
|
+
- **Token Exchange**: When requesting offline access tokens via token exchange, the flow will request expiring tokens
|
|
330
|
+
|
|
331
|
+
The resulting `Session` object will contain:
|
|
332
|
+
- `access_token`: The access token that will eventually expire
|
|
333
|
+
- `expires`: The expiration time for the access token
|
|
334
|
+
- `refresh_token`: A token that can be used to refresh the access token
|
|
335
|
+
- `refresh_token_expires`: The expiration time for the refresh token
|
|
336
|
+
|
|
337
|
+
### Refreshing Access Tokens
|
|
338
|
+
|
|
339
|
+
When your access token expires, you can use the refresh token to obtain a new access token using the `ShopifyAPI::Auth::RefreshToken.refresh_access_token` method.
|
|
340
|
+
|
|
341
|
+
#### Input
|
|
342
|
+
| Parameter | Type | Required? | Notes |
|
|
343
|
+
| -------------- | -------- | :-------: | ----------------------------------------------------------------------------------------------- |
|
|
344
|
+
| `shop` | `String` | Yes | A Shopify domain name in the form `{exampleshop}.myshopify.com`. |
|
|
345
|
+
| `refresh_token`| `String` | Yes | The refresh token from the session. |
|
|
346
|
+
|
|
347
|
+
#### Output
|
|
348
|
+
This method returns a new `ShopifyAPI::Auth::Session` object with a fresh access token and a new refresh token. Your app should store this new session to replace the expired one.
|
|
349
|
+
|
|
350
|
+
#### Example
|
|
351
|
+
```ruby
|
|
352
|
+
def refresh_session(shop, refresh_token)
|
|
353
|
+
begin
|
|
354
|
+
# Refresh the access token using the refresh token
|
|
355
|
+
new_session = ShopifyAPI::Auth::RefreshToken.refresh_access_token(
|
|
356
|
+
shop: shop,
|
|
357
|
+
refresh_token: refresh_token
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# Store the new session, replacing the old one
|
|
361
|
+
MyApp::SessionRepository.store_shop_session(new_session)
|
|
362
|
+
rescue ShopifyAPI::Errors::HttpResponseError => e
|
|
363
|
+
puts("Failed to refresh access token: #{e.message}")
|
|
364
|
+
raise e
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
```
|
|
368
|
+
#### Checking Token Expiration
|
|
369
|
+
The `Session` object provides helper methods to check if tokens have expired:
|
|
370
|
+
|
|
371
|
+
```ruby
|
|
372
|
+
session = MyApp::SessionRepository.retrieve_shop_session_by_shopify_domain(shop)
|
|
373
|
+
|
|
374
|
+
# Check if the access token has expired
|
|
375
|
+
if session.expired?
|
|
376
|
+
# Access token has expired, refresh it
|
|
377
|
+
new_session = ShopifyAPI::Auth::RefreshToken.refresh_access_token(
|
|
378
|
+
shop: session.shop,
|
|
379
|
+
refresh_token: session.refresh_token
|
|
380
|
+
)
|
|
381
|
+
MyApp::SessionRepository.store_shop_session(new_session)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Check if the refresh token has expired
|
|
385
|
+
if session.refresh_token_expired?
|
|
386
|
+
# Refresh token has expired, need to re-authenticate with OAuth
|
|
387
|
+
end
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
### Migrating Non-Expiring Tokens to Expiring Tokens
|
|
391
|
+
|
|
392
|
+
If you have existing non-expiring offline access tokens and want to migrate them to expiring tokens, you can use the `ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token` method. This performs a token exchange that converts your non-expiring offline token into an expiring one with a refresh token.
|
|
393
|
+
|
|
394
|
+
> [!WARNING]
|
|
395
|
+
> This is a **one-time, irreversible migration** per shop. Once you migrate a shop's token to an expiring token, you cannot convert it back to a non-expiring token. The shop would need to reinstall your app with `expiring_offline_access_tokens: false` in your Context configuration to obtain a new non-expiring token.
|
|
396
|
+
|
|
397
|
+
#### Input
|
|
398
|
+
| Parameter | Type | Required? | Notes |
|
|
399
|
+
| -------------- | -------- | :-------: | ----------------------------------------------------------------------------------------------- |
|
|
400
|
+
| `shop` | `String` | Yes | A Shopify domain name in the form `{exampleshop}.myshopify.com`. |
|
|
401
|
+
| `non_expiring_offline_token` | `String` | Yes | The non-expiring offline access token to migrate. |
|
|
402
|
+
|
|
403
|
+
#### Output
|
|
404
|
+
This method returns a new `ShopifyAPI::Auth::Session` object with an expiring access token and refresh token. Your app should store this new session to replace the non-expiring one.
|
|
405
|
+
|
|
406
|
+
#### Example
|
|
407
|
+
```ruby
|
|
408
|
+
def migrate_shop_to_expiring_offline_token(shop)
|
|
409
|
+
# Retrieve the existing non-expiring session
|
|
410
|
+
old_session = MyApp::SessionRepository.retrieve_shop_session_by_shopify_domain(shop)
|
|
411
|
+
|
|
412
|
+
# Migrate to expiring token
|
|
413
|
+
new_session = ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token(
|
|
414
|
+
shop: shop,
|
|
415
|
+
non_expiring_offline_token: old_session.access_token
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# Store the new expiring session, replacing the old one
|
|
419
|
+
MyApp::SessionRepository.store_shop_session(new_session)
|
|
420
|
+
end
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
#### Migration Strategy
|
|
424
|
+
When migrating your app to use expiring tokens, follow this order:
|
|
425
|
+
|
|
426
|
+
1. **Update your database schema** to add `expires_at` (timestamp), `refresh_token` (string) and `refresh_token_expires` (timestamp) columns to your session storage
|
|
427
|
+
2. **Implement refresh logic** in your app to handle token expiration using `ShopifyAPI::Auth::RefreshToken.refresh_access_token`
|
|
428
|
+
3. **Enable expiring tokens in your Context setup** so new installations will request and receive expiring tokens:
|
|
429
|
+
```ruby
|
|
430
|
+
ShopifyAPI::Context.setup(
|
|
431
|
+
expiring_offline_access_tokens: true,
|
|
432
|
+
# ... other config
|
|
433
|
+
)
|
|
434
|
+
```
|
|
435
|
+
4. **Migrate existing non-expiring tokens** for shops that have already installed your app using the migration method above
|
|
436
|
+
|
|
308
437
|
## Using OAuth Session to make authenticated API calls
|
|
309
438
|
Once your OAuth flow is complete, and you have persisted your `Session` object, you may use that `Session` object to make authenticated API calls.
|
|
310
439
|
|
data/docs/usage/webhooks.md
CHANGED
|
@@ -29,26 +29,9 @@ module WebhookHandler
|
|
|
29
29
|
end
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
**Note:** As of version 13.5.0 the `ShopifyAPI::Webhooks::Handler` class is still available to be used but will be removed in a future version of the gem.
|
|
33
|
-
|
|
34
32
|
### Best Practices
|
|
35
33
|
It is recommended that in order to respond quickly to the Shopify webhook request that the handler not do any heavy logic or network calls, rather it should simply enqueue the work in some job queue in order to be executed later.
|
|
36
34
|
|
|
37
|
-
### Webhook Handler for versions 13.4.0 and prior
|
|
38
|
-
If you want to register for an http webhook you need to implement a webhook handler which the `shopify_api` gem can use to determine how to process your webhook. You can make multiple implementations (one per topic) or you can make one implementation capable of handling all the topics you want to subscribe to. To do this simply make a module or class that includes or extends `ShopifyAPI::Webhooks::Handler` and implement the handle method which accepts the following named parameters: topic: `String`, shop: `String`, and body: `Hash[String, untyped]`. An example implementation is shown below:
|
|
39
|
-
|
|
40
|
-
```ruby
|
|
41
|
-
module WebhookHandler
|
|
42
|
-
extend ShopifyAPI::Webhooks::Handler
|
|
43
|
-
|
|
44
|
-
class << self
|
|
45
|
-
def handle(topic:, shop:, body:)
|
|
46
|
-
puts "Received webhook! topic: #{topic} shop: #{shop} body: #{body}"
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
```
|
|
51
|
-
|
|
52
35
|
## Add to Webhook Registry
|
|
53
36
|
|
|
54
37
|
The next step is to add all the webhooks you would like to subscribe to for any shop to the webhook registry. To do this you can call `ShopifyAPI::Webhooks::Registry.add_registration` for each webhook you would like to handle. `add_registration` accepts a topic string, a delivery_method symbol (currently supporting `:http`, `:event_bridge`, and `:pub_sub`), a webhook path (the relative path for an http webhook) and a handler. This only needs to be done once when the app is started and we recommend doing this at the same time that you setup `ShopifyAPI::Context`. An example is shown below to register an http webhook:
|
|
@@ -5,6 +5,7 @@ module ShopifyAPI
|
|
|
5
5
|
module AdminVersions
|
|
6
6
|
SUPPORTED_ADMIN_VERSIONS = T.let([
|
|
7
7
|
"unstable",
|
|
8
|
+
"2026-01",
|
|
8
9
|
"2025-10",
|
|
9
10
|
"2025-07",
|
|
10
11
|
"2025-04",
|
|
@@ -22,12 +23,7 @@ module ShopifyAPI
|
|
|
22
23
|
"2022-04",
|
|
23
24
|
"2022-01",
|
|
24
25
|
], T::Array[String])
|
|
25
|
-
|
|
26
|
-
LATEST_SUPPORTED_ADMIN_VERSION = T.let("2025-07", String)
|
|
27
|
-
RELEASE_CANDIDATE_ADMIN_VERSION = T.let("2025-10", String)
|
|
28
26
|
end
|
|
29
27
|
|
|
30
28
|
SUPPORTED_ADMIN_VERSIONS = ShopifyAPI::AdminVersions::SUPPORTED_ADMIN_VERSIONS
|
|
31
|
-
LATEST_SUPPORTED_ADMIN_VERSION = ShopifyAPI::AdminVersions::LATEST_SUPPORTED_ADMIN_VERSION
|
|
32
|
-
RELEASE_CANDIDATE_ADMIN_VERSION = ShopifyAPI::AdminVersions::RELEASE_CANDIDATE_ADMIN_VERSION
|
|
33
29
|
end
|
|
@@ -13,6 +13,8 @@ module ShopifyAPI
|
|
|
13
13
|
const :expires_in, T.nilable(Integer)
|
|
14
14
|
const :associated_user, T.nilable(AssociatedUser)
|
|
15
15
|
const :associated_user_scope, T.nilable(String)
|
|
16
|
+
const :refresh_token, T.nilable(String)
|
|
17
|
+
const :refresh_token_expires_in, T.nilable(Integer)
|
|
16
18
|
|
|
17
19
|
sig { returns(T::Boolean) }
|
|
18
20
|
def online_token?
|
|
@@ -29,7 +31,9 @@ module ShopifyAPI
|
|
|
29
31
|
session == other.session &&
|
|
30
32
|
expires_in == other.expires_in &&
|
|
31
33
|
associated_user == other.associated_user &&
|
|
32
|
-
associated_user_scope == other.associated_user_scope
|
|
34
|
+
associated_user_scope == other.associated_user_scope &&
|
|
35
|
+
refresh_token == other.refresh_token &&
|
|
36
|
+
refresh_token_expires_in == other.refresh_token_expires_in
|
|
33
37
|
end
|
|
34
38
|
end
|
|
35
39
|
end
|
|
@@ -71,7 +71,12 @@ module ShopifyAPI
|
|
|
71
71
|
"Invalid state in OAuth callback." unless state == auth_query.state
|
|
72
72
|
|
|
73
73
|
null_session = Auth::Session.new(shop: auth_query.shop)
|
|
74
|
-
body = {
|
|
74
|
+
body = {
|
|
75
|
+
client_id: Context.api_key,
|
|
76
|
+
client_secret: Context.api_secret_key,
|
|
77
|
+
code: auth_query.code,
|
|
78
|
+
expiring: Context.expiring_offline_access_tokens ? 1 : 0, # Only applicable for offline tokens
|
|
79
|
+
}
|
|
75
80
|
|
|
76
81
|
client = Clients::HttpClient.new(session: null_session, base_path: "/admin/oauth")
|
|
77
82
|
response = begin
|
|
@@ -100,7 +105,7 @@ module ShopifyAPI
|
|
|
100
105
|
else
|
|
101
106
|
SessionCookie.new(
|
|
102
107
|
value: session.id,
|
|
103
|
-
expires: session.
|
|
108
|
+
expires: session.expires ? session.expires : nil,
|
|
104
109
|
)
|
|
105
110
|
end
|
|
106
111
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ShopifyAPI
|
|
5
|
+
module Auth
|
|
6
|
+
module RefreshToken
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig do
|
|
13
|
+
params(
|
|
14
|
+
shop: String,
|
|
15
|
+
refresh_token: String,
|
|
16
|
+
).returns(ShopifyAPI::Auth::Session)
|
|
17
|
+
end
|
|
18
|
+
def refresh_access_token(shop:, refresh_token:)
|
|
19
|
+
unless ShopifyAPI::Context.setup?
|
|
20
|
+
raise ShopifyAPI::Errors::ContextNotSetupError,
|
|
21
|
+
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
shop_session = ShopifyAPI::Auth::Session.new(shop:)
|
|
25
|
+
body = {
|
|
26
|
+
client_id: ShopifyAPI::Context.api_key,
|
|
27
|
+
client_secret: ShopifyAPI::Context.api_secret_key,
|
|
28
|
+
grant_type: "refresh_token",
|
|
29
|
+
refresh_token:,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
client = Clients::HttpClient.new(session: shop_session, base_path: "/admin/oauth")
|
|
33
|
+
response = begin
|
|
34
|
+
client.request(
|
|
35
|
+
Clients::HttpRequest.new(
|
|
36
|
+
http_method: :post,
|
|
37
|
+
path: "access_token",
|
|
38
|
+
body:,
|
|
39
|
+
body_type: "application/json",
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
rescue ShopifyAPI::Errors::HttpResponseError => error
|
|
43
|
+
ShopifyAPI::Context.logger.debug("Failed to refresh access token: #{error.message}")
|
|
44
|
+
raise error
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h
|
|
48
|
+
|
|
49
|
+
Session.from(
|
|
50
|
+
shop:,
|
|
51
|
+
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -30,6 +30,12 @@ module ShopifyAPI
|
|
|
30
30
|
sig { returns(T.nilable(String)) }
|
|
31
31
|
attr_accessor :shopify_session_id
|
|
32
32
|
|
|
33
|
+
sig { returns(T.nilable(String)) }
|
|
34
|
+
attr_accessor :refresh_token
|
|
35
|
+
|
|
36
|
+
sig { returns(T.nilable(Time)) }
|
|
37
|
+
attr_accessor :refresh_token_expires
|
|
38
|
+
|
|
33
39
|
sig { returns(T::Boolean) }
|
|
34
40
|
def online?
|
|
35
41
|
@is_online
|
|
@@ -40,6 +46,11 @@ module ShopifyAPI
|
|
|
40
46
|
@expires ? @expires < Time.now : false
|
|
41
47
|
end
|
|
42
48
|
|
|
49
|
+
sig { returns(T::Boolean) }
|
|
50
|
+
def refresh_token_expired?
|
|
51
|
+
@refresh_token_expires ? @refresh_token_expires < Time.now + 60 : false
|
|
52
|
+
end
|
|
53
|
+
|
|
43
54
|
sig do
|
|
44
55
|
params(
|
|
45
56
|
shop: String,
|
|
@@ -52,10 +63,12 @@ module ShopifyAPI
|
|
|
52
63
|
is_online: T.nilable(T::Boolean),
|
|
53
64
|
associated_user: T.nilable(AssociatedUser),
|
|
54
65
|
shopify_session_id: T.nilable(String),
|
|
66
|
+
refresh_token: T.nilable(String),
|
|
67
|
+
refresh_token_expires: T.nilable(Time),
|
|
55
68
|
).void
|
|
56
69
|
end
|
|
57
70
|
def initialize(shop:, id: nil, state: nil, access_token: "", scope: [], associated_user_scope: nil, expires: nil,
|
|
58
|
-
is_online: nil, associated_user: nil, shopify_session_id: nil)
|
|
71
|
+
is_online: nil, associated_user: nil, shopify_session_id: nil, refresh_token: nil, refresh_token_expires: nil)
|
|
59
72
|
@id = T.let(id || SecureRandom.uuid, String)
|
|
60
73
|
@shop = shop
|
|
61
74
|
@state = state
|
|
@@ -68,6 +81,8 @@ module ShopifyAPI
|
|
|
68
81
|
@associated_user = associated_user
|
|
69
82
|
@is_online = T.let(is_online || !associated_user.nil?, T::Boolean)
|
|
70
83
|
@shopify_session_id = shopify_session_id
|
|
84
|
+
@refresh_token = refresh_token
|
|
85
|
+
@refresh_token_expires = refresh_token_expires
|
|
71
86
|
end
|
|
72
87
|
|
|
73
88
|
class << self
|
|
@@ -105,6 +120,10 @@ module ShopifyAPI
|
|
|
105
120
|
expires = Time.now + access_token_response.expires_in.to_i
|
|
106
121
|
end
|
|
107
122
|
|
|
123
|
+
if access_token_response.refresh_token_expires_in
|
|
124
|
+
refresh_token_expires = Time.now + access_token_response.refresh_token_expires_in.to_i
|
|
125
|
+
end
|
|
126
|
+
|
|
108
127
|
new(
|
|
109
128
|
id: id,
|
|
110
129
|
shop: shop,
|
|
@@ -115,31 +134,28 @@ module ShopifyAPI
|
|
|
115
134
|
associated_user: associated_user,
|
|
116
135
|
expires: expires,
|
|
117
136
|
shopify_session_id: access_token_response.session,
|
|
137
|
+
refresh_token: access_token_response.refresh_token,
|
|
138
|
+
refresh_token_expires: refresh_token_expires,
|
|
118
139
|
)
|
|
119
140
|
end
|
|
120
|
-
|
|
121
|
-
sig { params(str: String).returns(Session) }
|
|
122
|
-
def deserialize(str)
|
|
123
|
-
Oj.load(str)
|
|
124
|
-
end
|
|
125
141
|
end
|
|
126
142
|
|
|
127
143
|
sig { params(other: Session).returns(Session) }
|
|
128
144
|
def copy_attributes_from(other)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
145
|
+
@shop = other.shop
|
|
146
|
+
@state = other.state
|
|
147
|
+
@access_token = other.access_token
|
|
148
|
+
@scope = other.scope
|
|
149
|
+
@associated_user_scope = other.associated_user_scope
|
|
150
|
+
@expires = other.expires
|
|
151
|
+
@associated_user = other.associated_user
|
|
152
|
+
@is_online = other.online?
|
|
153
|
+
@shopify_session_id = other.shopify_session_id
|
|
154
|
+
@refresh_token = other.refresh_token
|
|
155
|
+
@refresh_token_expires = other.refresh_token_expires
|
|
135
156
|
self
|
|
136
157
|
end
|
|
137
158
|
|
|
138
|
-
sig { returns(String) }
|
|
139
|
-
def serialize
|
|
140
|
-
Oj.dump(self)
|
|
141
|
-
end
|
|
142
|
-
|
|
143
159
|
alias_method :eql?, :==
|
|
144
160
|
sig { params(other: T.nilable(Session)).returns(T::Boolean) }
|
|
145
161
|
def ==(other)
|
|
@@ -153,8 +169,9 @@ module ShopifyAPI
|
|
|
153
169
|
(!(expires.nil? ^ other.expires.nil?) && (expires.nil? || expires.to_i == other.expires.to_i)) &&
|
|
154
170
|
online? == other.online? &&
|
|
155
171
|
associated_user == other.associated_user &&
|
|
156
|
-
shopify_session_id == other.shopify_session_id
|
|
157
|
-
|
|
172
|
+
shopify_session_id == other.shopify_session_id &&
|
|
173
|
+
refresh_token == other.refresh_token &&
|
|
174
|
+
refresh_token_expires&.to_i == other.refresh_token_expires&.to_i
|
|
158
175
|
else
|
|
159
176
|
false
|
|
160
177
|
end
|
|
@@ -49,6 +49,10 @@ module ShopifyAPI
|
|
|
49
49
|
requested_token_type: requested_token_type.serialize,
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
if requested_token_type == RequestedTokenType::OFFLINE_ACCESS_TOKEN
|
|
53
|
+
body.merge!({ expiring: ShopifyAPI::Context.expiring_offline_access_tokens ? 1 : 0 })
|
|
54
|
+
end
|
|
55
|
+
|
|
52
56
|
client = Clients::HttpClient.new(session: shop_session, base_path: "/admin/oauth")
|
|
53
57
|
response = begin
|
|
54
58
|
client.request(
|
|
@@ -74,6 +78,52 @@ module ShopifyAPI
|
|
|
74
78
|
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
|
|
75
79
|
)
|
|
76
80
|
end
|
|
81
|
+
|
|
82
|
+
sig do
|
|
83
|
+
params(
|
|
84
|
+
shop: String,
|
|
85
|
+
non_expiring_offline_token: String,
|
|
86
|
+
).returns(ShopifyAPI::Auth::Session)
|
|
87
|
+
end
|
|
88
|
+
def migrate_to_expiring_token(shop:, non_expiring_offline_token:)
|
|
89
|
+
unless ShopifyAPI::Context.setup?
|
|
90
|
+
raise ShopifyAPI::Errors::ContextNotSetupError,
|
|
91
|
+
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
|
|
95
|
+
body = {
|
|
96
|
+
client_id: ShopifyAPI::Context.api_key,
|
|
97
|
+
client_secret: ShopifyAPI::Context.api_secret_key,
|
|
98
|
+
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
|
|
99
|
+
subject_token: non_expiring_offline_token,
|
|
100
|
+
subject_token_type: RequestedTokenType::OFFLINE_ACCESS_TOKEN.serialize,
|
|
101
|
+
requested_token_type: RequestedTokenType::OFFLINE_ACCESS_TOKEN.serialize,
|
|
102
|
+
expiring: "1",
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
client = Clients::HttpClient.new(session: shop_session, base_path: "/admin/oauth")
|
|
106
|
+
response = begin
|
|
107
|
+
client.request(
|
|
108
|
+
Clients::HttpRequest.new(
|
|
109
|
+
http_method: :post,
|
|
110
|
+
path: "access_token",
|
|
111
|
+
body: body,
|
|
112
|
+
body_type: "application/json",
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
rescue ShopifyAPI::Errors::HttpResponseError => error
|
|
116
|
+
ShopifyAPI::Context.logger.debug("Failed to migrate to expiring offline token: #{error.message}")
|
|
117
|
+
raise error
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h
|
|
121
|
+
|
|
122
|
+
Session.from(
|
|
123
|
+
shop: shop,
|
|
124
|
+
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
|
|
125
|
+
)
|
|
126
|
+
end
|
|
77
127
|
end
|
|
78
128
|
end
|
|
79
129
|
end
|
|
@@ -8,21 +8,15 @@ module ShopifyAPI
|
|
|
8
8
|
sig do
|
|
9
9
|
params(
|
|
10
10
|
shop: String,
|
|
11
|
-
storefront_access_token: T.nilable(String),
|
|
12
11
|
private_token: T.nilable(String),
|
|
13
12
|
public_token: T.nilable(String),
|
|
14
13
|
api_version: T.nilable(String),
|
|
15
14
|
).void
|
|
16
15
|
end
|
|
17
|
-
def initialize(shop,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
the public token as the second argument. Also, you may want to look into using
|
|
22
|
-
the Storefront private access token instead:
|
|
23
|
-
https://shopify.dev/docs/api/usage/authentication#getting-started-with-private-access
|
|
24
|
-
WARNING
|
|
25
|
-
ShopifyAPI::Logger.deprecated(warning, "15.0.0")
|
|
16
|
+
def initialize(shop, private_token: nil, public_token: nil, api_version: nil)
|
|
17
|
+
token = private_token || public_token
|
|
18
|
+
if token.nil?
|
|
19
|
+
raise ArgumentError, "Storefront client requires either private_token or public_token to be provided"
|
|
26
20
|
end
|
|
27
21
|
|
|
28
22
|
session = Auth::Session.new(
|
|
@@ -32,7 +26,7 @@ module ShopifyAPI
|
|
|
32
26
|
is_online: false,
|
|
33
27
|
)
|
|
34
28
|
super(session: session, base_path: "/api", api_version: api_version)
|
|
35
|
-
@storefront_access_token = T.let(
|
|
29
|
+
@storefront_access_token = T.let(token, String)
|
|
36
30
|
@storefront_auth_header = T.let(
|
|
37
31
|
private_token.nil? ? "X-Shopify-Storefront-Access-Token" : "Shopify-Storefront-Private-Token",
|
|
38
32
|
String,
|
|
@@ -112,6 +112,8 @@ module ShopifyAPI
|
|
|
112
112
|
def serialized_error(response)
|
|
113
113
|
body = {}
|
|
114
114
|
body["errors"] = response.body["errors"] if response.body["errors"]
|
|
115
|
+
body["error"] = response.body["error"] if response.body["error"]
|
|
116
|
+
body["error_description"] = response.body["error_description"] if response.body["error"]
|
|
115
117
|
|
|
116
118
|
if response.headers["x-request-id"]
|
|
117
119
|
id = T.must(response.headers["x-request-id"])[0]
|
data/lib/shopify_api/context.rb
CHANGED
|
@@ -7,7 +7,7 @@ module ShopifyAPI
|
|
|
7
7
|
|
|
8
8
|
@api_key = T.let("", String)
|
|
9
9
|
@api_secret_key = T.let("", String)
|
|
10
|
-
@api_version = T.let(
|
|
10
|
+
@api_version = T.let("", String)
|
|
11
11
|
@api_host = T.let(nil, T.nilable(String))
|
|
12
12
|
@scope = T.let(Auth::AuthScopes.new, Auth::AuthScopes)
|
|
13
13
|
@is_private = T.let(false, T::Boolean)
|
|
@@ -25,6 +25,7 @@ module ShopifyAPI
|
|
|
25
25
|
@rest_disabled = T.let(false, T.nilable(T::Boolean))
|
|
26
26
|
|
|
27
27
|
@rest_resource_loader = T.let(nil, T.nilable(Zeitwerk::Loader))
|
|
28
|
+
@expiring_offline_access_tokens = T.let(false, T::Boolean)
|
|
28
29
|
|
|
29
30
|
class << self
|
|
30
31
|
extend T::Sig
|
|
@@ -47,6 +48,7 @@ module ShopifyAPI
|
|
|
47
48
|
api_host: T.nilable(String),
|
|
48
49
|
response_as_struct: T.nilable(T::Boolean),
|
|
49
50
|
rest_disabled: T.nilable(T::Boolean),
|
|
51
|
+
expiring_offline_access_tokens: T.nilable(T::Boolean),
|
|
50
52
|
).void
|
|
51
53
|
end
|
|
52
54
|
def setup(
|
|
@@ -65,7 +67,8 @@ module ShopifyAPI
|
|
|
65
67
|
old_api_secret_key: nil,
|
|
66
68
|
api_host: nil,
|
|
67
69
|
response_as_struct: false,
|
|
68
|
-
rest_disabled: false
|
|
70
|
+
rest_disabled: false,
|
|
71
|
+
expiring_offline_access_tokens: false
|
|
69
72
|
)
|
|
70
73
|
unless ShopifyAPI::AdminVersions::SUPPORTED_ADMIN_VERSIONS.include?(api_version)
|
|
71
74
|
raise Errors::UnsupportedVersionError,
|
|
@@ -86,6 +89,7 @@ module ShopifyAPI
|
|
|
86
89
|
@old_api_secret_key = old_api_secret_key
|
|
87
90
|
@response_as_struct = response_as_struct
|
|
88
91
|
@rest_disabled = rest_disabled
|
|
92
|
+
@expiring_offline_access_tokens = T.must(expiring_offline_access_tokens)
|
|
89
93
|
@log_level = if valid_log_level?(log_level)
|
|
90
94
|
log_level.to_sym
|
|
91
95
|
else
|
|
@@ -101,8 +105,8 @@ module ShopifyAPI
|
|
|
101
105
|
@rest_resource_loader&.setup
|
|
102
106
|
@rest_resource_loader&.unload
|
|
103
107
|
|
|
104
|
-
# No resources for the unstable version
|
|
105
|
-
return if api_version == "unstable"
|
|
108
|
+
# No resources for the unstable version
|
|
109
|
+
return if api_version == "unstable"
|
|
106
110
|
|
|
107
111
|
version_folder_name = api_version.gsub("-", "_")
|
|
108
112
|
|
|
@@ -148,6 +152,9 @@ module ShopifyAPI
|
|
|
148
152
|
sig { returns(T.nilable(String)) }
|
|
149
153
|
attr_reader :private_shop, :user_agent_prefix, :old_api_secret_key, :host, :api_host
|
|
150
154
|
|
|
155
|
+
sig { returns(T::Boolean) }
|
|
156
|
+
attr_reader :expiring_offline_access_tokens
|
|
157
|
+
|
|
151
158
|
sig { returns(T::Boolean) }
|
|
152
159
|
def embedded?
|
|
153
160
|
@is_embedded
|
|
@@ -155,7 +162,7 @@ module ShopifyAPI
|
|
|
155
162
|
|
|
156
163
|
sig { returns(T::Boolean) }
|
|
157
164
|
def setup?
|
|
158
|
-
[api_key, api_secret_key, T.must(host)].none?(&:empty?)
|
|
165
|
+
[api_key, api_secret_key, api_version, T.must(host)].none?(&:empty?)
|
|
159
166
|
end
|
|
160
167
|
|
|
161
168
|
sig { returns(T.nilable(Auth::Session)) }
|