diff --git a/CHANGELOG.md b/CHANGELOG.md index 09f835b65..0960288b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ Unreleased ---------- +* Extracted class - `PostAuthenticateTasks` to handle post authenticate tasks. To learn more, see [post authenticate tasks](/docs/shopify_app/authentication.md#post-authenticate-tasks). [1819](https://github.com/Shopify/shopify_app/pull/1819) 22.0.1 (March 12, 2024) ---------- diff --git a/app/controllers/shopify_app/callback_controller.rb b/app/controllers/shopify_app/callback_controller.rb index cedd3369c..6131521fe 100644 --- a/app/controllers/shopify_app/callback_controller.rb +++ b/app/controllers/shopify_app/callback_controller.rb @@ -23,7 +23,16 @@ def callback return respond_with_user_token_flow if start_user_token_flow?(api_session) - perform_post_authenticate_jobs(api_session) + if ShopifyApp::VERSION < "23.0" + # deprecated in 23.0 + if ShopifyApp.configuration.custom_post_authenticate_tasks.present? + ShopifyApp.configuration.post_authenticate_tasks.perform(api_session) + else + perform_post_authenticate_jobs(api_session) + end + else + ShopifyApp.configuration.post_authenticate_tasks.perform(api_session) + end redirect_to_app if check_billing(api_session) end diff --git a/docs/Upgrading.md b/docs/Upgrading.md index 68c63c803..03c3b1828 100644 --- a/docs/Upgrading.md +++ b/docs/Upgrading.md @@ -40,6 +40,17 @@ We also recommend the use of a staging site which matches your production enviro If you do run into issues, we recommend looking at our [debugging tips.](https://github.com/Shopify/shopify_app/blob/main/docs/Troubleshooting.md#debugging-tips) +## Unreleased +#### (v23.0.0) - Deprecated methods in CallbackController +The following methods from `ShopifyApp::CallbackController` have been deprecated in `v23.0.0` +- `perform_after_authenticate_job` +- `install_webhooks` +- `perform_post_authenticate_jobs` + +If you have overwritten these methods in your callback controller to modify the behavior of the inherited `CallbackController`, you will need to +update your app to use configurable option `config.custom_post_authenticate_tasks` instead. See [post authenticate tasks](/docs/shopify_app/authentication.md#post-authenticate-tasks) +for more information. + ## Upgrading to `v22.0.0` #### Dropped support for Ruby 2.x Support for Ruby 2.x has been dropped as it is no longer supported. You'll need to upgrade to 3.x.x diff --git a/docs/shopify_app/authentication.md b/docs/shopify_app/authentication.md index 0e9b95f07..a466e7838 100644 --- a/docs/shopify_app/authentication.md +++ b/docs/shopify_app/authentication.md @@ -12,7 +12,7 @@ See [*Getting started with session token authentication*](https://shopify.dev/do * [OAuth callback](#oauth-callback) * [Customizing callback controller](#customizing-callback-controller) -* [Run jobs after the OAuth flow](#run-jobs-after-the-oauth-flow) +* [Run jobs after the OAuth flow](#post-authenticate-tasks) * [Rotate API credentials](#rotate-api-credentials) * [Making authenticated API requests after authorization](#making-authenticated-api-requests-after-authorization) @@ -26,15 +26,11 @@ The default callback controller [`ShopifyApp::CallbackController`](../../app/con 1. Logging into the shop and resetting the session 2. Storing the session to the `SessionRepository` -3. [Installing Webhooks](/docs/shopify_app/webhooks.md) -4. [Setting Scripttags](/docs/shopify_app/script-tags.md) -5. [Run jobs after the OAuth flow](#run-jobs-after-the-oauth-flow) -6. Redirecting to the return address - +3. [Post authenticate tasks](#post-authenticate-tasks) +4. Redirecting to the return address #### Customizing callback controller -If the app needs to do some extra work, it can define and configure the route to a custom callback controller. -Inheriting from `ShopifyApp::CallbackController` and hook into or override any of the defined helper methods. +If you need to define a custom callback controller to handle your app's use case, you can configure the callback route to your controller. Example: @@ -42,11 +38,9 @@ Example: ```ruby # web/app/controllers/my_custom_callback_controller.rb -class MyCustomCallbackController < ShopifyApp::CallbackController - private - - def install_webhooks(session) - # My custom override/definition to install webhooks +class MyCustomCallbackController + def callback + # My custom callback logic end end ``` @@ -69,11 +63,39 @@ Rails.application.routes.draw do end ``` -### Run jobs after the OAuth flow +### Post Authenticate tasks +After authentication is complete, a few tasks are run by default by PostAuthenticateTasks: +1. [Installing Webhooks](/docs/shopify_app/webhooks.md) +2. [Run configured after_authenticate_job](#after_authenticate_job) + +The [PostAuthenticateTasks](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/auth/post_authenticate_tasks.rb) +class is responsible for triggering the webhooks manager for webhooks registration, and enqueue jobs from [after_authenticate_job](#after_authenticate_job). + +If you simply need to enqueue more jobs to run after authenticate, use [after_authenticate_job](#after_authenticate_job) to define these jobs. + +If your post authentication tasks is more complex and is different than just installing webhooks and enqueuing jobs, +you can customize the post authenticate tasks by creating a new class that has a `self.perform(session)` method, +and configuring `custom_post_authenticate_tasks` in the initializer. + +```ruby +# my_custom_post_authenticate_task.rb +class MyCustomPostAuthenticateTask + def self.perform(session) + # This will be triggered after OAuth callback and token exchange completion + end +end + +# config/initializers/shopify_app.rb +ShopifyApp.configure do |config| + config.custom_post_authenticate_tasks = "MyCustomPostAuthenticateTask" +end +``` + +#### after_authenticate_job See [`ShopifyApp::AfterAuthenticateJob`](/lib/generators/shopify_app/add_after_authenticate_job/templates/after_authenticate_job.rb). -If your app needs to perform specific actions after the user is authenticated successfully (i.e. every time a new session is created), ShopifyApp can queue or run a job of your choosing (note that we already provide support for automatically creating Webhooks and Scripttags). To configure the after authenticate job, update your initializer as follows: +If your app needs to perform specific actions after the user is authenticated successfully (i.e. every time a new session is created), ShopifyApp can queue or run a job of your choosing. To configure the after authenticate job, update your initializer as follows: ```ruby ShopifyApp.configure do |config| diff --git a/docs/shopify_app/webhooks.md b/docs/shopify_app/webhooks.md index f1c0386c1..b47ce2898 100644 --- a/docs/shopify_app/webhooks.md +++ b/docs/shopify_app/webhooks.md @@ -18,7 +18,7 @@ ShopifyApp.configure do |config| end ``` -When the [OAuth callback](/docs/shopify_app/authentication.md#oauth-callback) is completed successfully, ShopifyApp will queue a background job which will ensure all the specified webhooks exist for that shop. Because this runs on every OAuth callback, it means your app will always have the webhooks it needs even if the user uninstalls and re-installs the app. +When the [OAuth callback](/docs/shopify_app/authentication.md#oauth-callback) or token exchange is completed successfully, ShopifyApp will queue a background job which will ensure all the specified webhooks exist for that shop. Because this runs on every OAuth callback, it means your app will always have the webhooks it needs even if the user uninstalls and re-installs the app. ShopifyApp also provides a [WebhooksController](/app/controllers/shopify_app/webhooks_controller.rb) that receives webhooks and queues a job based on the received topic. For example, if you register the webhook from above, then all you need to do is create a job called `CartsUpdateJob`. The job will be queued with 2 params: `shop_domain` and `webhook` (which is the webhook body). diff --git a/lib/shopify_app.rb b/lib/shopify_app.rb index 2f109ec00..0880d3a67 100644 --- a/lib/shopify_app.rb +++ b/lib/shopify_app.rb @@ -54,6 +54,9 @@ def self.use_webpacker? require "shopify_app/controller_concerns/webhook_verification" require "shopify_app/controller_concerns/token_exchange" + # Auth helpers + require "shopify_app/auth/post_authenticate_tasks" + # jobs require "shopify_app/jobs/webhooks_manager_job" diff --git a/lib/shopify_app/auth/post_authenticate_tasks.rb b/lib/shopify_app/auth/post_authenticate_tasks.rb new file mode 100644 index 000000000..44a3cc8aa --- /dev/null +++ b/lib/shopify_app/auth/post_authenticate_tasks.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module ShopifyApp + module Auth + class PostAuthenticateTasks + class << self + def perform(session) + ShopifyApp::Logger.debug("Performing post authenticate tasks") + # Ensure we use the shop session to install webhooks + session_for_shop = session.online? ? shop_session(session) : session + + install_webhooks(session_for_shop) + + perform_after_authenticate_job(session) + end + + private + + def shop_session(session) + ShopifyApp::SessionRepository.retrieve_shop_session_by_shopify_domain(session.shop) + end + + def install_webhooks(session) + ShopifyApp::Logger.debug("PostAuthenticateTasks: Installing webhooks") + return unless ShopifyApp.configuration.has_webhooks? + + WebhooksManager.queue(session.shop, session.access_token) + end + + def perform_after_authenticate_job(session) + ShopifyApp::Logger.debug("PostAuthenticateTasks: Performing after_authenticate_job") + config = ShopifyApp.configuration.after_authenticate_job + + return unless config && config[:job].present? + + job = config[:job] + job = job.constantize if job.is_a?(String) + + if config[:inline] == true + job.perform_now(shop_domain: session.shop) + else + job.perform_later(shop_domain: session.shop) + end + end + end + end + end +end diff --git a/lib/shopify_app/configuration.rb b/lib/shopify_app/configuration.rb index 07299dabe..9fb2c425f 100644 --- a/lib/shopify_app/configuration.rb +++ b/lib/shopify_app/configuration.rb @@ -29,6 +29,9 @@ class Configuration attr_writer :login_callback_url attr_accessor :embedded_redirect_url + # customize post authenticate tasks + attr_accessor :custom_post_authenticate_tasks + # customise ActiveJob queue names attr_accessor :scripttags_manager_queue_name attr_accessor :webhooks_manager_queue_name @@ -54,6 +57,8 @@ def initialize @scripttags_manager_queue_name = Rails.application.config.active_job.queue_name @webhooks_manager_queue_name = Rails.application.config.active_job.queue_name @disable_webpacker = ENV["SHOPIFY_APP_DISABLE_WEBPACKER"].present? + + log_callback_controller_method_deprecation end def login_url @@ -130,6 +135,48 @@ def use_new_embedded_auth_strategy? def online_token_configured? !ShopifyApp.configuration.user_session_repository.blank? && ShopifyApp::SessionRepository.user_storage.present? end + + def post_authenticate_tasks + @post_authenticate_tasks || begin + if custom_post_authenticate_tasks + custom_class = if custom_post_authenticate_tasks.respond_to?(:safe_constantize) + custom_post_authenticate_tasks.safe_constantize + else + custom_post_authenticate_tasks + end + end + + task_class = custom_class || ShopifyApp::Auth::PostAuthenticateTasks + + [ + :perform, + ].each do |method| + raise( + ::ShopifyApp::ConfigurationError, + "Missing method - '#{method}' for custom_post_authenticate_tasks", + ) unless task_class.respond_to?(method) + end + + task_class + end + end + + private + + def log_callback_controller_method_deprecation + return unless Rails.env.development? + + # TODO: Remove this before releasing v23.0.0 + message = <<~EOS + ================================================ + => Upcoming deprecation in v23.0: + * 'CallbackController::perform_after_authenticate_job' and related methods 'install_webhooks', 'perform_after_authenticate_job' + * will be deprecated from CallbackController in the next major release. If you need to customize + * post authentication tasks, see https://github.com/Shopify/shopify_app/blob/main/docs/shopify_app/authentication.md#post-authenticate-tasks + ================================================ + EOS + puts message + end end class BillingConfiguration diff --git a/test/controllers/callback_controller_test.rb b/test/controllers/callback_controller_test.rb index 96eb53846..8c7dcb258 100644 --- a/test/controllers/callback_controller_test.rb +++ b/test/controllers/callback_controller_test.rb @@ -6,6 +6,13 @@ module Shopify class AfterAuthenticateJob < ActiveJob::Base def perform; end end + + class CustomPostAuthenticateTasks + class << self + def perform(session) + end + end + end end class CartsUpdateJob < ActiveJob::Base @@ -50,6 +57,7 @@ class CallbackControllerTest < ActionController::TestCase end teardown do + ShopifyApp.configuration.custom_post_authenticate_tasks = nil SessionRepository.shop_storage.clear end @@ -163,6 +171,7 @@ class CallbackControllerTest < ActionController::TestCase end test "#callback starts the WebhooksManager if webhooks are configured" do + # Deprecated in 23.0, tests moved to PostAuthenticateTasksTest ShopifyApp.configure do |config| config.webhooks = [{ topic: "carts/update", address: "example-app.com/webhooks" }] end @@ -174,10 +183,10 @@ class CallbackControllerTest < ActionController::TestCase end test "#callback doesn't run the WebhooksManager if no webhooks are configured" do + # Deprecated in 23.0, tests moved to PostAuthenticateTasksTest ShopifyApp.configure do |config| config.webhooks = [] end - ShopifyApp::WebhooksManager.add_registrations ShopifyApp::WebhooksManager.expects(:queue).never @@ -186,6 +195,7 @@ class CallbackControllerTest < ActionController::TestCase end test "#callback calls #perform_after_authenticate_job and performs inline when inline is true" do + # Deprecated in 23.0, tests moved to PostAuthenticateTasksTest ShopifyApp.configure do |config| config.after_authenticate_job = { job: Shopify::AfterAuthenticateJob, inline: true } end @@ -197,6 +207,7 @@ class CallbackControllerTest < ActionController::TestCase end test "#callback calls #perform_after_authenticate_job and performs asynchronous when inline isn't true" do + # Deprecated in 23.0, tests moved to PostAuthenticateTasksTest ShopifyApp.configure do |config| config.after_authenticate_job = { job: Shopify::AfterAuthenticateJob, inline: false } end @@ -208,6 +219,7 @@ class CallbackControllerTest < ActionController::TestCase end test "#callback doesn't call #perform_after_authenticate_job if job is nil" do + # Deprecated in 23.0, tests moved to PostAuthenticateTasksTest ShopifyApp.configure do |config| config.after_authenticate_job = { job: nil, inline: false } end @@ -219,6 +231,7 @@ class CallbackControllerTest < ActionController::TestCase end test "#callback calls #perform_after_authenticate_job and performs async if inline isn't present" do + # Deprecated in 23.0, tests moved to PostAuthenticateTasksTest ShopifyApp.configure do |config| config.after_authenticate_job = { job: Shopify::AfterAuthenticateJob } end @@ -230,6 +243,7 @@ class CallbackControllerTest < ActionController::TestCase end test "#callback calls #perform_after_authenticate_job constantizes from a string to a class" do + # Deprecated in 23.0, tests moved to PostAuthenticateTasksTest ShopifyApp.configure do |config| config.after_authenticate_job = { job: "Shopify::AfterAuthenticateJob", inline: false } end @@ -241,6 +255,7 @@ class CallbackControllerTest < ActionController::TestCase end test "#callback calls #perform_after_authenticate_job raises if the string is not a valid job class" do + # Deprecated in 23.0, tests moved to PostAuthenticateTasksTest ShopifyApp.configure do |config| config.after_authenticate_job = { job: "InvalidJobClassThatDoesNotExist", inline: false } end @@ -291,6 +306,7 @@ class CallbackControllerTest < ActionController::TestCase end test "#callback performs install_webhook job after authentication" do + # Deprecated in 23.0, tests moved to PostAuthenticateTasksTest mock_oauth ShopifyApp.configure do |config| @@ -304,6 +320,7 @@ class CallbackControllerTest < ActionController::TestCase end test "#callback performs install_webhook job with an offline session after an online session OAuth" do + # Deprecated in 23.0, tests moved to PostAuthenticateTasksTest ShopifyApp.configure do |config| config.webhooks = [{ topic: "carts/update", address: "example-app.com/webhooks" }] end @@ -321,6 +338,7 @@ class CallbackControllerTest < ActionController::TestCase end test "#callback performs after_authenticate job after authentication" do + # Deprecated in 23.0, tests moved to PostAuthenticateTasksTest mock_oauth ShopifyApp.configure do |config| @@ -333,6 +351,46 @@ class CallbackControllerTest < ActionController::TestCase assert_response 302 end + test "#callback calls post_authenticate_tasks if custom_post_authenticate_tasks is set" do + mock_oauth + + ShopifyApp.configure do |_config| + ShopifyApp.configuration.custom_post_authenticate_tasks = Shopify::CustomPostAuthenticateTasks + end + + Shopify::CustomPostAuthenticateTasks.expects(:perform).with(@stubbed_session) + + get :callback, params: @callback_params + assert_response 302 + end + + test "#callback does not call post_authenticate_tasks if custom_post_authenticate_tasks is not set" do + mock_oauth + + ShopifyApp.configure do |_config| + ShopifyApp.configuration.custom_post_authenticate_tasks = nil + end + + Shopify::CustomPostAuthenticateTasks.expects(:perform).never + + get :callback, params: @callback_params + assert_response 302 + end + + test "#callback calls methods in callback controller if custom_post_authenticate_tasks is not set" do + mock_oauth + + ShopifyApp.configure do |_config| + ShopifyApp.configuration.custom_post_authenticate_tasks = nil + end + + CallbackController.any_instance.expects(:install_webhooks) + CallbackController.any_instance.expects(:perform_after_authenticate_job) + + get :callback, params: @callback_params + assert_response 302 + end + private def mock_oauth(cookie: @stubbed_cookie, session: @stubbed_session) diff --git a/test/shopify_app/auth/post_authenticate_tasks_test.rb b/test/shopify_app/auth/post_authenticate_tasks_test.rb new file mode 100644 index 000000000..2706f36a2 --- /dev/null +++ b/test/shopify_app/auth/post_authenticate_tasks_test.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require "test_helper" + +module Shopify + class AfterAuthenticateJob < ActiveJob::Base + def perform; end + end +end + +class CartsUpdateJob < ActiveJob::Base + extend ShopifyAPI::Webhooks::Handler + + class << self + def handle(topic:, shop:, body:) + perform_later(topic: topic, shop_domain: shop, webhook: body) + end + end + + def perform; end +end + +class PostAuthenticateTasksTest < ActiveSupport::TestCase + SHOP_DOMAIN = "shop.myshopify.io" + + setup do + ShopifyApp::SessionRepository.shop_storage = ShopifyApp::InMemoryShopSessionStore + ShopifyApp::SessionRepository.user_storage = ShopifyApp::InMemoryShopSessionStore + ShopifyAppConfigurer.setup_context + + @offline_session = ShopifyAPI::Auth::Session.new(shop: SHOP_DOMAIN, access_token: "offline_token") + @online_session = ShopifyAPI::Auth::Session.new(shop: SHOP_DOMAIN, access_token: "online_token", is_online: true) + + ShopifyApp::SessionRepository.store_shop_session(@offline_session) + end + + test "#perform runs WebhooksManager job if webhooks are configured" do + ShopifyApp.configure do |config| + config.webhooks = [{ topic: "carts/update", address: "example-app.com/webhooks" }] + end + + ShopifyApp::WebhooksManager.expects(:queue).with(SHOP_DOMAIN, "offline_token") + + ShopifyApp::Auth::PostAuthenticateTasks.perform(@offline_session) + end + + test "#perform doesn't run the WebhooksManager if no webhooks are configured" do + ShopifyApp.configure do |config| + config.webhooks = [] + end + + ShopifyApp::WebhooksManager.expects(:queue).never + + ShopifyApp::Auth::PostAuthenticateTasks.perform(@offline_session) + end + + test "#perform triggers install_webhook job with an offline session after an online session OAuth" do + ShopifyApp.configure do |config| + config.webhooks = [{ topic: "carts/update", address: "example-app.com/webhooks" }] + end + ShopifyApp::WebhooksManager.expects(:queue).with(SHOP_DOMAIN, "offline_token") + + ShopifyApp::Auth::PostAuthenticateTasks.perform(@online_session) + ensure + ShopifyApp::SessionRepository.shop_storage.clear + end + + test "#perform calls AfterAuthenticateJob and performs inline when inline is true" do + ShopifyApp.configure do |config| + config.after_authenticate_job = { job: Shopify::AfterAuthenticateJob, inline: true } + end + + Shopify::AfterAuthenticateJob.expects(:perform_now).with(shop_domain: SHOP_DOMAIN) + + ShopifyApp::Auth::PostAuthenticateTasks.perform(@offline_session) + end + + test "#perform calls AfterAuthenticateJob and performs asynchronous when inline isn't true" do + ShopifyApp.configure do |config| + config.after_authenticate_job = { job: Shopify::AfterAuthenticateJob, inline: false } + end + + Shopify::AfterAuthenticateJob.expects(:perform_later).with(shop_domain: SHOP_DOMAIN) + + ShopifyApp::Auth::PostAuthenticateTasks.perform(@offline_session) + end + + test "#perform doesn't call AfterAuthenticateJob if job is nil" do + ShopifyApp.configure do |config| + config.after_authenticate_job = { job: nil, inline: false } + end + + Shopify::AfterAuthenticateJob.expects(:perform_later).never + + ShopifyApp::Auth::PostAuthenticateTasks.perform(@offline_session) + end + + test "#perform calls AfterAuthenticateJob and performs async if inline isn't present" do + ShopifyApp.configure do |config| + config.after_authenticate_job = { job: Shopify::AfterAuthenticateJob } + end + + Shopify::AfterAuthenticateJob.expects(:perform_later).with(shop_domain: SHOP_DOMAIN) + + ShopifyApp::Auth::PostAuthenticateTasks.perform(@offline_session) + end + + test "#perform calls AfterAuthenticateJob constantizes from a string to a class" do + ShopifyApp.configure do |config| + config.after_authenticate_job = { job: "Shopify::AfterAuthenticateJob", inline: false } + end + + Shopify::AfterAuthenticateJob.expects(:perform_later).with(shop_domain: SHOP_DOMAIN) + + ShopifyApp::Auth::PostAuthenticateTasks.perform(@offline_session) + end + + test "#perform calls AfterAuthenticateJob raises if the string is not a valid job class" do + ShopifyApp.configure do |config| + config.after_authenticate_job = { job: "InvalidJobClassThatDoesNotExist", inline: false } + end + + assert_raise NameError do + ShopifyApp::Auth::PostAuthenticateTasks.perform(@offline_session) + end + end +end diff --git a/test/shopify_app/configuration_test.rb b/test/shopify_app/configuration_test.rb index fcbd95b5d..fa2bf6ced 100644 --- a/test/shopify_app/configuration_test.rb +++ b/test/shopify_app/configuration_test.rb @@ -2,6 +2,18 @@ require "test_helper" +module Shopify + class CustomPostAuthenticateTasks + def self.perform + end + end + + class InvalidPostAuthenticateTasksClass + def self.not_perform + end + end +end + class ConfigurationTest < ActiveSupport::TestCase setup do ShopifyApp.configuration = nil @@ -271,4 +283,36 @@ class ConfigurationTest < ActiveSupport::TestCase refute ShopifyApp.configuration.online_token_configured? end + + test "#post_authenticate_tasks defaults to ShopifyApp::Auth::PostAuthenticateTasks" do + assert_equal ShopifyApp::Auth::PostAuthenticateTasks, ShopifyApp.configuration.post_authenticate_tasks + end + + test "#post_authenticate_tasks can be set to a custom class" do + ShopifyApp.configure do |config| + config.custom_post_authenticate_tasks = Shopify::CustomPostAuthenticateTasks + end + + assert_equal Shopify::CustomPostAuthenticateTasks, ShopifyApp.configuration.post_authenticate_tasks + end + + test "#post_authenticate_tasks can be set to a custom class name" do + ShopifyApp.configure do |config| + config.custom_post_authenticate_tasks = "Shopify::CustomPostAuthenticateTasks" + end + + assert_equal Shopify::CustomPostAuthenticateTasks, ShopifyApp.configuration.post_authenticate_tasks + end + + test "post_authenticate_tasks raises an error if the custom class does not respond to perform" do + ShopifyApp.configure do |config| + config.custom_post_authenticate_tasks = Shopify::InvalidPostAuthenticateTasksClass + end + + error = assert_raises(ShopifyApp::ConfigurationError) do + ShopifyApp.configuration.post_authenticate_tasks + end + + assert_equal "Missing method - 'perform' for custom_post_authenticate_tasks", error.message + end end