From e95157945c36fac07451ffb2d5836e7d45afde31 Mon Sep 17 00:00:00 2001 From: Elizabeth Kenyon Date: Tue, 23 Jan 2024 14:37:32 -0600 Subject: [PATCH] Add additional method for new webhook handler --- docs/usage/webhooks.md | 44 +++++- lib/shopify_api/webhooks/handler.rb | 22 ++- lib/shopify_api/webhooks/registry.rb | 12 +- test/test_helpers/fake_webhook_handler.rb | 4 +- test/test_helpers/new_fake_webhook_handler.rb | 24 ++++ test/webhooks/registry_test.rb | 129 +++++++++++++++++- 6 files changed, 215 insertions(+), 20 deletions(-) create mode 100644 test/test_helpers/new_fake_webhook_handler.rb diff --git a/docs/usage/webhooks.md b/docs/usage/webhooks.md index 203e23cb8..a2d8f2986 100644 --- a/docs/usage/webhooks.md +++ b/docs/usage/webhooks.md @@ -7,10 +7,42 @@ If using in the Rails framework, we highly recommend you use the [shopify_app](h ## Create a Webhook Handler +### New webhook handler as of Version 13.5.0 +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::WebhookHandler` and implement the `handle_webhook` method which accepts the following named parameters: data: `Hash[Symbol, untyped]`. An example implementation is shown below: + +You will also have to define a method called `use_handle_webhook?` which returns a boolean. If this method returns `true` then the `handle_webhook` method will be called when a webhook is received. If this method returns `false` then the `handle` method will be called when a webhook is received. The `handle` method is the old way of handling webhooks and will be deprecated in a future version of the gem. When using this method you will not have access to the `webhook_id` or `api_version` of the webhook. + +`data` will have the following keys +- `:topic` - The topic of the webhook +- `:shop` - The shop domain of the webhook +- `:body` - The body of the webhook +- `:webhook_id` - The id of the webhook event to [avoid duplicates](https://shopify.dev/docs/apps/webhooks/best-practices#ignore-duplicates) +- `:api_version` - The api version of the webhook + +```ruby +module WebhookHandler + extend ShopifyAPI::Webhooks::Handler + + class << self + def handle_webhook(data) + puts "Received webhook! topic: #{data[:topic]} shop: #{data[:shop]} body: #{data[:body]} webhook_id: #{data[:webhook_id]} api_version: #{data[:api_version]" + end + + def use_handle_webhook? + true + end + end +end +``` + +**Note:** As of version 13.5.0 the `handle` method is still available to be used but will be removed in a future version of the gem. It is recommended that you use the `handle_webhook` method instead. + + +### Webhook Handler for versions 13.4.0 and prior 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::WebhookHandler` 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: ```ruby -module WebhookHandler +module WebhookHandler extend ShopifyAPI::Webhooks::Handler class << self @@ -28,18 +60,18 @@ end 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: ```ruby -registration = ShopifyAPI::Webhooks::Registry.add_registration(topic: "orders/create", +registration = ShopifyAPI::Webhooks::Registry.add_registration(topic: "orders/create", delivery_method: :http, handler: WebhookHandler, - path: 'callback/orders/create') + path: 'callback/orders/create') ``` If you are only interested in particular fields, you can optionally filter the data sent by Shopify by specifying the `fields` parameter. Note that you will still receive a webhook request from Shopify every time the resource is updated, but only the specified fields will be sent: ```ruby registration = ShopifyAPI::Webhooks::Registry.add_registration( - topic: "orders/create", - delivery_method: :http, - handler: WebhookHandler, + topic: "orders/create", + delivery_method: :http, + handler: WebhookHandler, path: 'callback/orders/create', fields: ["number","note"] # this can also be a single comma separated string ) diff --git a/lib/shopify_api/webhooks/handler.rb b/lib/shopify_api/webhooks/handler.rb index 2c420269e..eafd88c1a 100644 --- a/lib/shopify_api/webhooks/handler.rb +++ b/lib/shopify_api/webhooks/handler.rb @@ -4,15 +4,29 @@ module ShopifyAPI module Webhooks module Handler + include Kernel extend T::Sig extend T::Helpers - interface! + abstract! sig do - abstract.params(topic: String, shop: String, body: T::Hash[String, T.untyped], - webhook_id: T.nilable(String), api_version: T.nilable(String)).void + params(data: T::Hash[Symbol, T.untyped]).void + end + def handle_webhook(data) + if use_handle_webhook? + raise NotImplementedError, "You must implement the `handle_webhook` method in your webhook handler class." + end + end + + sig do + abstract.params(topic: String, shop: String, body: T::Hash[String, T.untyped]).void + end + def handle(topic:, shop:, body:); end + + sig { returns(T::Boolean) } + def use_handle_webhook? + false end - def handle(topic:, shop:, body:, webhook_id:, api_version:); end end end end diff --git a/lib/shopify_api/webhooks/registry.rb b/lib/shopify_api/webhooks/registry.rb index 1e32a01cb..9adbc7978 100644 --- a/lib/shopify_api/webhooks/registry.rb +++ b/lib/shopify_api/webhooks/registry.rb @@ -193,8 +193,16 @@ def process(request) raise Errors::NoWebhookHandler, "No webhook handler found for topic: #{request.topic}." end - handler.handle(topic: request.topic, shop: request.shop, body: request.parsed_body, - webhook_id: request.webhook_id, api_version: request.api_version) + if handler.use_handle_webhook? + handler.handle_webhook({ topic: request.topic, shop: request.shop, body: request.parsed_body, + webhook_id: request.webhook_id, api_version: request.api_version, }) + else + handler.handle(topic: request.topic, shop: request.shop, body: request.parsed_body) + ShopifyAPI::Logger.warn(<<~WARNING) + DEPRECATED: Use ShopifyAPI::Webhooks::Handler#handle_webhook + instead of ShopifyAPI::Webhooks::Handler#handle + WARNING + end end private diff --git a/test/test_helpers/fake_webhook_handler.rb b/test/test_helpers/fake_webhook_handler.rb index a8272613b..766b6eb41 100644 --- a/test/test_helpers/fake_webhook_handler.rb +++ b/test/test_helpers/fake_webhook_handler.rb @@ -9,8 +9,8 @@ def initialize(handler) @handler = handler end - def handle(topic:, shop:, body:, webhook_id:, api_version:) - @handler.call(topic, shop, body, webhook_id, api_version) + def handle(topic:, shop:, body:) + @handler.call(topic, shop, body) end end end diff --git a/test/test_helpers/new_fake_webhook_handler.rb b/test/test_helpers/new_fake_webhook_handler.rb new file mode 100644 index 000000000..9732b364d --- /dev/null +++ b/test/test_helpers/new_fake_webhook_handler.rb @@ -0,0 +1,24 @@ +# typed: false +# frozen_string_literal: true + +module TestHelpers + class NewFakeWebhookHandler + include ShopifyAPI::Webhooks::Handler + + def initialize(handler) + @handler = handler + end + + def handle(topic:, shop:, body:) + @handler.call(topic, shop, body) + end + + def handle_webhook(data) + @handler.call(data) + end + + def use_handle_webhook? + true + end + end +end diff --git a/test/webhooks/registry_test.rb b/test/webhooks/registry_test.rb index 79bb952c8..64034af5e 100644 --- a/test/webhooks/registry_test.rb +++ b/test/webhooks/registry_test.rb @@ -42,12 +42,33 @@ def test_process handler_called = false handler = TestHelpers::FakeWebhookHandler.new( - lambda do |topic, shop, body, webhook_id, api_version| + lambda do |topic, shop, body,| assert_equal(@topic, topic) assert_equal(@shop, shop) assert_equal({}, body) - assert_equal("b1234-eefd-4c9e-9520-049845a02082", webhook_id) - assert_equal("2024-01", api_version) + handler_called = true + end, + ) + + ShopifyAPI::Webhooks::Registry.add_registration( + topic: @topic, path: "path", delivery_method: :http, handler: handler, + ) + + ShopifyAPI::Webhooks::Registry.process(@webhook_request) + + assert(handler_called) + end + + def test_process_new_handler + handler_called = false + + handler = TestHelpers::NewFakeWebhookHandler.new( + lambda do |data| + assert_equal(@topic, data[:topic]) + assert_equal(@shop, data[:shop]) + assert_equal({}, data[:body]) + assert_equal("b1234-eefd-4c9e-9520-049845a02082", data[:webhook_id]) + assert_equal("2024-01", data[:api_version]) handler_called = true end, ) @@ -325,7 +346,7 @@ def do_registration_test(delivery_method, path, fields: nil, metafield_namespace delivery_method: delivery_method, path: path, handler: TestHelpers::FakeWebhookHandler.new( - lambda do |topic, shop, body, webhook_id, api_version| + lambda do |topic, shop, body| end, ), fields: fields, @@ -351,7 +372,78 @@ def do_registration_test(delivery_method, path, fields: nil, metafield_namespace delivery_method: delivery_method, path: "#{path}-updated", handler: TestHelpers::FakeWebhookHandler.new( - lambda do |topic, shop, body, webhook_id, api_version| + lambda do |topic, shop, body| + end, + ), + ) + update_registration_response = ShopifyAPI::Webhooks::Registry.register_all( + session: @session, + )[0] + + assert(update_registration_response.success) + assert_equal(queries[delivery_method][:register_update_response], update_registration_response.body) + end + + def do_registration_test_new_handler(delivery_method, path, fields: nil, metafield_namespaces: nil) + ShopifyAPI::Webhooks::Registry.clear + + check_query_body = { query: queries[delivery_method][:check_query], variables: nil } + + stub_request(:post, @url) + .with(body: JSON.dump(check_query_body)) + .to_return({ status: 200, body: JSON.dump(queries[delivery_method][:check_empty_response]) }) + + add_query_type = if fields + :register_add_query_with_fields + elsif metafield_namespaces + :register_add_query_with_metafield_namespaces + else + :register_add_query + end + add_response_type = if fields + :register_add_with_fields_response + elsif metafield_namespaces + :register_add_with_metafield_namespaces_response + else + :register_add_response + end + + stub_request(:post, @url) + .with(body: JSON.dump({ query: queries[delivery_method][add_query_type], variables: nil })) + .to_return({ status: 200, body: JSON.dump(queries[delivery_method][add_response_type]) }) + + ShopifyAPI::Webhooks::Registry.add_registration( + topic: @topic, + delivery_method: delivery_method, + path: path, + handler: TestHelpers::NewFakeWebhookHandler.new( + lambda do |data| + end, + ), + fields: fields, + metafield_namespaces: metafield_namespaces, + ) + registration_response = ShopifyAPI::Webhooks::Registry.register_all( + session: @session, + )[0] + + assert(registration_response.success) + assert_equal(queries[delivery_method][add_response_type], registration_response.body) + + stub_request(:post, @url) + .with(body: JSON.dump(check_query_body)) + .to_return({ status: 200, body: JSON.dump(queries[delivery_method][:check_existing_response]) }) + + stub_request(:post, @url) + .with(body: JSON.dump({ query: queries[delivery_method][:register_update_query], variables: nil })) + .to_return({ status: 200, body: JSON.dump(queries[delivery_method][:register_update_response]) }) + + ShopifyAPI::Webhooks::Registry.add_registration( + topic: @topic, + delivery_method: delivery_method, + path: "#{path}-updated", + handler: TestHelpers::NewFakeWebhookHandler.new( + lambda do |data| end, ), ) @@ -376,7 +468,32 @@ def do_registration_check_error_test(delivery_method, path) delivery_method: delivery_method, path: path, handler: TestHelpers::FakeWebhookHandler.new( - lambda do |topic, shop, body, webhook_id, api_version| + lambda do |topic, shop, body| + end, + ), + ) + + assert_raises(StandardError) do + ShopifyAPI::Webhooks::Registry.register_all( + session: @session, + ) + end + end + + def do_registration_check_error_test_new_handler(delivery_method, path) + ShopifyAPI::Webhooks::Registry.clear + body = { query: queries[delivery_method][:check_query], variables: nil } + + stub_request(:post, @url) + .with(body: JSON.dump(body)) + .to_return(status: 304) + + ShopifyAPI::Webhooks::Registry.add_registration( + topic: @topic, + delivery_method: delivery_method, + path: path, + handler: TestHelpers::NewFakeWebhookHandler.new( + lambda do |data| end, ), )