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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/api_update_reminder.yml +2 -2
  3. data/.github/workflows/api_update_reminder_on_release.yml +2 -2
  4. data/.github/workflows/build.yml +3 -4
  5. data/.github/workflows/close-waiting-for-response-issues.yml +1 -1
  6. data/.github/workflows/remove-labels-on-activity.yml +1 -1
  7. data/BREAKING_CHANGES_FOR_V15.md +98 -3
  8. data/BREAKING_CHANGES_FOR_V16.md +76 -0
  9. data/CHANGELOG.md +15 -1
  10. data/Gemfile.lock +1 -1
  11. data/RELEASING.md +108 -17
  12. data/REST_RESOURCES.md +161 -0
  13. data/docs/getting_started.md +2 -1
  14. data/docs/usage/oauth.md +129 -0
  15. data/docs/usage/webhooks.md +0 -17
  16. data/lib/shopify_api/admin_versions.rb +1 -5
  17. data/lib/shopify_api/auth/oauth/access_token_response.rb +5 -1
  18. data/lib/shopify_api/auth/oauth.rb +7 -2
  19. data/lib/shopify_api/auth/refresh_token.rb +57 -0
  20. data/lib/shopify_api/auth/session.rb +36 -19
  21. data/lib/shopify_api/auth/token_exchange.rb +50 -0
  22. data/lib/shopify_api/clients/graphql/storefront.rb +5 -11
  23. data/lib/shopify_api/clients/http_client.rb +2 -0
  24. data/lib/shopify_api/context.rb +12 -5
  25. data/lib/shopify_api/rest/resources/2025_10/abandoned_checkout.rb +194 -0
  26. data/lib/shopify_api/rest/resources/2025_10/access_scope.rb +62 -0
  27. data/lib/shopify_api/rest/resources/2025_10/apple_pay_certificate.rb +109 -0
  28. data/lib/shopify_api/rest/resources/2025_10/application_charge.rb +113 -0
  29. data/lib/shopify_api/rest/resources/2025_10/application_credit.rb +95 -0
  30. data/lib/shopify_api/rest/resources/2025_10/article.rb +269 -0
  31. data/lib/shopify_api/rest/resources/2025_10/asset.rb +122 -0
  32. data/lib/shopify_api/rest/resources/2025_10/assigned_fulfillment_order.rb +92 -0
  33. data/lib/shopify_api/rest/resources/2025_10/balance.rb +58 -0
  34. data/lib/shopify_api/rest/resources/2025_10/blog.rb +166 -0
  35. data/lib/shopify_api/rest/resources/2025_10/cancellation_request.rb +87 -0
  36. data/lib/shopify_api/rest/resources/2025_10/carrier_service.rb +120 -0
  37. data/lib/shopify_api/rest/resources/2025_10/checkout.rb +213 -0
  38. data/lib/shopify_api/rest/resources/2025_10/collect.rb +146 -0
  39. data/lib/shopify_api/rest/resources/2025_10/collection.rb +114 -0
  40. data/lib/shopify_api/rest/resources/2025_10/collection_listing.rb +159 -0
  41. data/lib/shopify_api/rest/resources/2025_10/comment.rb +287 -0
  42. data/lib/shopify_api/rest/resources/2025_10/country.rb +141 -0
  43. data/lib/shopify_api/rest/resources/2025_10/currency.rb +61 -0
  44. data/lib/shopify_api/rest/resources/2025_10/custom_collection.rb +191 -0
  45. data/lib/shopify_api/rest/resources/2025_10/customer.rb +328 -0
  46. data/lib/shopify_api/rest/resources/2025_10/deprecated_api_call.rb +61 -0
  47. data/lib/shopify_api/rest/resources/2025_10/discount_code.rb +226 -0
  48. data/lib/shopify_api/rest/resources/2025_10/dispute.rb +115 -0
  49. data/lib/shopify_api/rest/resources/2025_10/dispute_evidence.rb +121 -0
  50. data/lib/shopify_api/rest/resources/2025_10/dispute_file_upload.rb +85 -0
  51. data/lib/shopify_api/rest/resources/2025_10/draft_order.rb +279 -0
  52. data/lib/shopify_api/rest/resources/2025_10/event.rb +152 -0
  53. data/lib/shopify_api/rest/resources/2025_10/fulfillment.rb +235 -0
  54. data/lib/shopify_api/rest/resources/2025_10/fulfillment_event.rb +167 -0
  55. data/lib/shopify_api/rest/resources/2025_10/fulfillment_order.rb +326 -0
  56. data/lib/shopify_api/rest/resources/2025_10/fulfillment_request.rb +116 -0
  57. data/lib/shopify_api/rest/resources/2025_10/fulfillment_service.rb +134 -0
  58. data/lib/shopify_api/rest/resources/2025_10/gift_card.rb +222 -0
  59. data/lib/shopify_api/rest/resources/2025_10/gift_card_adjustment.rb +122 -0
  60. data/lib/shopify_api/rest/resources/2025_10/image.rb +161 -0
  61. data/lib/shopify_api/rest/resources/2025_10/inventory_item.rb +112 -0
  62. data/lib/shopify_api/rest/resources/2025_10/inventory_level.rb +183 -0
  63. data/lib/shopify_api/rest/resources/2025_10/location.rb +171 -0
  64. data/lib/shopify_api/rest/resources/2025_10/locations_for_move.rb +60 -0
  65. data/lib/shopify_api/rest/resources/2025_10/marketing_event.rb +213 -0
  66. data/lib/shopify_api/rest/resources/2025_10/metafield.rb +348 -0
  67. data/lib/shopify_api/rest/resources/2025_10/mobile_platform_application.rb +120 -0
  68. data/lib/shopify_api/rest/resources/2025_10/order.rb +503 -0
  69. data/lib/shopify_api/rest/resources/2025_10/order_risk.rb +148 -0
  70. data/lib/shopify_api/rest/resources/2025_10/page.rb +198 -0
  71. data/lib/shopify_api/rest/resources/2025_10/payment.rb +98 -0
  72. data/lib/shopify_api/rest/resources/2025_10/payment_gateway.rb +147 -0
  73. data/lib/shopify_api/rest/resources/2025_10/payment_transaction.rb +117 -0
  74. data/lib/shopify_api/rest/resources/2025_10/payout.rb +101 -0
  75. data/lib/shopify_api/rest/resources/2025_10/policy.rb +73 -0
  76. data/lib/shopify_api/rest/resources/2025_10/price_rule.rb +227 -0
  77. data/lib/shopify_api/rest/resources/2025_10/product.rb +227 -0
  78. data/lib/shopify_api/rest/resources/2025_10/product_listing.rb +200 -0
  79. data/lib/shopify_api/rest/resources/2025_10/product_resource_feedback.rb +92 -0
  80. data/lib/shopify_api/rest/resources/2025_10/province.rb +136 -0
  81. data/lib/shopify_api/rest/resources/2025_10/recurring_application_charge.rb +184 -0
  82. data/lib/shopify_api/rest/resources/2025_10/redirect.rb +143 -0
  83. data/lib/shopify_api/rest/resources/2025_10/refund.rb +158 -0
  84. data/lib/shopify_api/rest/resources/2025_10/resource_feedback.rb +77 -0
  85. data/lib/shopify_api/rest/resources/2025_10/script_tag.rb +159 -0
  86. data/lib/shopify_api/rest/resources/2025_10/shipping_zone.rb +87 -0
  87. data/lib/shopify_api/rest/resources/2025_10/shop.rb +231 -0
  88. data/lib/shopify_api/rest/resources/2025_10/smart_collection.rb +220 -0
  89. data/lib/shopify_api/rest/resources/2025_10/storefront_access_token.rb +91 -0
  90. data/lib/shopify_api/rest/resources/2025_10/tender_transaction.rb +97 -0
  91. data/lib/shopify_api/rest/resources/2025_10/theme.rb +127 -0
  92. data/lib/shopify_api/rest/resources/2025_10/transaction.rb +194 -0
  93. data/lib/shopify_api/rest/resources/2025_10/usage_charge.rb +106 -0
  94. data/lib/shopify_api/rest/resources/2025_10/user.rb +142 -0
  95. data/lib/shopify_api/rest/resources/2025_10/variant.rb +212 -0
  96. data/lib/shopify_api/rest/resources/2025_10/webhook.rb +173 -0
  97. data/lib/shopify_api/version.rb +1 -1
  98. data/lib/shopify_api/webhooks/registration.rb +3 -3
  99. data/lib/shopify_api/webhooks/registry.rb +3 -13
  100. data/lib/shopify_api/webhooks/{handler.rb → webhook_handler.rb} +0 -12
  101. data/lib/shopify_api.rb +1 -1
  102. data/shopify_api.gemspec +1 -1
  103. 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
 
@@ -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 = { client_id: Context.api_key, client_secret: Context.api_secret_key, code: auth_query.code }
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.online? ? session.expires : nil,
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
- JSON.parse(other.serialize).keys.each do |key|
130
- next if key.include?("^")
131
-
132
- variable_name = "@#{key}"
133
- instance_variable_set(variable_name, other.instance_variable_get(variable_name))
134
- end
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, storefront_access_token = nil, private_token: nil, public_token: nil, api_version: nil)
18
- unless storefront_access_token.nil?
19
- warning = <<~WARNING
20
- DEPRECATED: Use the named parameters for the Storefront token instead of passing
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(T.must(private_token || public_token || storefront_access_token), String)
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]
@@ -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(LATEST_SUPPORTED_ADMIN_VERSION, String)
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 or the release candidate version
105
- return if api_version == "unstable" || api_version == RELEASE_CANDIDATE_ADMIN_VERSION
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)) }