diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d50f3f..ce6dac46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Note: For changes to the API, see https://shopify.dev/changelog?filter=api ## Unreleased - [#1254](https://github.com/Shopify/shopify-api-ruby/pull/1254) Introduce token exchange API for fetching access tokens. This feature is currently unstable and cannot be used yet. +- [#1268](https://github.com/Shopify/shopify-api-ruby/pull/1268) Add [new webhook handler interface](https://github.com/Shopify/shopify-api-ruby/blob/main/docs/usage/webhooks.md#create-a-webhook-handler) to provide `webhook_id ` and `api_version` information to webhook handlers. ## 13.4.0 - [#1210](https://github.com/Shopify/shopify-api-ruby/pull/1246) Add context option `response_as_struct` to allow GraphQL API responses to be accessed via dot notation. diff --git a/docs/usage/webhooks.md b/docs/usage/webhooks.md index 203e23cb..df9fa626 100644 --- a/docs/usage/webhooks.md +++ b/docs/usage/webhooks.md @@ -7,10 +7,37 @@ If using in the Rails framework, we highly recommend you use the [shopify_app](h ## Create a Webhook Handler -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: +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: data: `WebhookMetadata`. An example implementation is shown below: + +`data` will have the following keys +- `topic`, `String` - The topic of the webhook +- `shop`, `String` - The shop domain of the webhook +- `body`, `T::Hash[String, T.untyped]`- The body of the webhook +- `webhook_id`, `String` - The id of the webhook event to [avoid duplicates](https://shopify.dev/docs/apps/webhooks/best-practices#ignore-duplicates) +- `api_version`, `String` - The api version of the webhook ```ruby -module WebhookHandler +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 + end +end +``` + +**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. + +### Best Practices +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. + +### 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::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: + +```ruby +module WebhookHandler extend ShopifyAPI::Webhooks::Handler class << self @@ -21,25 +48,23 @@ module WebhookHandler end ``` -**Note:** 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. - ## Add to Webhook Registry 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 2430a4a2..22a4de96 100644 --- a/lib/shopify_api/webhooks/handler.rb +++ b/lib/shopify_api/webhooks/handler.rb @@ -3,13 +3,36 @@ module ShopifyAPI module Webhooks + class WebhookMetadata < T::Struct + const :topic, String + const :shop, String + const :body, T::Hash[String, T.untyped] + const :api_version, String + const :webhook_id, String + end + module Handler + include Kernel extend T::Sig extend T::Helpers interface! - sig { abstract.params(topic: String, shop: String, body: T::Hash[String, T.untyped]).void } + sig do + abstract.params(topic: String, shop: String, body: T::Hash[String, T.untyped]).void + end def handle(topic:, shop:, body:); end end + + module WebhookHandler + include Kernel + extend T::Sig + extend T::Helpers + interface! + + sig do + abstract.params(data: WebhookMetadata).void + end + def handle(data:); end + end end end diff --git a/lib/shopify_api/webhooks/registration.rb b/lib/shopify_api/webhooks/registration.rb index c8a2e6c9..eb786b70 100644 --- a/lib/shopify_api/webhooks/registration.rb +++ b/lib/shopify_api/webhooks/registration.rb @@ -13,7 +13,7 @@ class Registration sig { returns(String) } attr_reader :topic - sig { returns(T.nilable(Handler)) } + sig { returns(T.nilable(T.any(Handler, WebhookHandler))) } attr_reader :handler sig { returns(T.nilable(T::Array[String])) } @@ -23,7 +23,7 @@ class Registration attr_reader :metafield_namespaces sig do - params(topic: String, path: String, handler: T.nilable(Handler), + params(topic: String, path: String, handler: T.nilable(T.any(Handler, WebhookHandler)), fields: T.nilable(T.any(String, T::Array[String])), metafield_namespaces: T.nilable(T::Array[String])).void end diff --git a/lib/shopify_api/webhooks/registry.rb b/lib/shopify_api/webhooks/registry.rb index b357730e..9a97f501 100644 --- a/lib/shopify_api/webhooks/registry.rb +++ b/lib/shopify_api/webhooks/registry.rb @@ -17,7 +17,7 @@ class << self params(topic: String, delivery_method: Symbol, path: String, - handler: T.nilable(Handler), + handler: T.nilable(T.any(Handler, WebhookHandler)), fields: T.nilable(T.any(String, T::Array[String])), metafield_namespaces: T.nilable(T::Array[String])).void end @@ -193,7 +193,18 @@ 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) + if handler.is_a?(WebhookHandler) + handler.handle(data: WebhookMetadata.new(topic: request.topic, shop: request.shop, + body: request.parsed_body, api_version: request.api_version, webhook_id: request.webhook_id)) + else + handler.handle(topic: request.topic, shop: request.shop, body: request.parsed_body) + ShopifyAPI::Logger.deprecated( + "DEPRECATED: Use ShopifyAPI::Webhooks::WebhookHandler#handle \ + instead of ShopifyAPI::Webhooks::Handler#handle. + https://github.com/Shopify/shopify-api-ruby/blob/main/docs/usage/webhooks.md#create-a-webhook-handler", + "14.0.0", + ) + end end private diff --git a/lib/shopify_api/webhooks/request.rb b/lib/shopify_api/webhooks/request.rb index c283def9..23e07735 100644 --- a/lib/shopify_api/webhooks/request.rb +++ b/lib/shopify_api/webhooks/request.rb @@ -22,6 +22,16 @@ def shop T.cast(@headers["x-shopify-shop-domain"], String) end + sig { returns(String) } + def api_version + T.cast(@headers["x-shopify-api-version"], String) + end + + sig { returns(String) } + def webhook_id + T.cast(@headers["x-shopify-webhook-id"], String) + end + sig { override.returns(String) } def to_signable_string @raw_body diff --git a/test/test_helpers/fake_webhook_handler.rb b/test/test_helpers/fake_webhook_handler.rb index 766b6eb4..f69ef601 100644 --- a/test/test_helpers/fake_webhook_handler.rb +++ b/test/test_helpers/fake_webhook_handler.rb @@ -1,6 +1,8 @@ # typed: false # frozen_string_literal: true +require_relative "../../lib/shopify_api/webhooks/handler" + module TestHelpers class FakeWebhookHandler include ShopifyAPI::Webhooks::Handler 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 00000000..6a989489 --- /dev/null +++ b/test/test_helpers/new_fake_webhook_handler.rb @@ -0,0 +1,17 @@ +# typed: false +# frozen_string_literal: true + +require_relative "../../lib/shopify_api/webhooks/handler" +module TestHelpers + class NewFakeWebhookHandler + include ShopifyAPI::Webhooks::WebhookHandler + + def initialize(handler) + @handler = handler + end + + def handle(data:) + @handler.call(data) + end + end +end diff --git a/test/webhooks/registry_test.rb b/test/webhooks/registry_test.rb index bc78fc63..37bac209 100644 --- a/test/webhooks/registry_test.rb +++ b/test/webhooks/registry_test.rb @@ -23,6 +23,8 @@ def setup "x-shopify-topic" => @topic, "x-shopify-hmac-sha256" => Base64.encode64(hmac), "x-shopify-shop-domain" => @shop, + "x-shopify-webhook-id" => "b1234-eefd-4c9e-9520-049845a02082", + "x-shopify-api-version" => "2024-01", } @webhook_request = ShopifyAPI::Webhooks::Request.new(raw_body: "{}", headers: @headers) @@ -40,7 +42,7 @@ def test_process handler_called = false handler = TestHelpers::FakeWebhookHandler.new( - lambda do |topic, shop, body| + lambda do |topic, shop, body,| assert_equal(@topic, topic) assert_equal(@shop, shop) assert_equal({}, body) @@ -57,6 +59,29 @@ def test_process 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, + ) + + 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_hmac_validation_fails headers = { "x-shopify-topic" => "some/topic", @@ -359,6 +384,77 @@ def do_registration_test(delivery_method, path, fields: nil, metafield_namespace assert_equal(queries[delivery_method][:register_update_response], update_registration_response.body) end + def do_registration_new_handler_test(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, + ), + ) + 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_check_error_test(delivery_method, path) ShopifyAPI::Webhooks::Registry.clear body = { query: queries[delivery_method][:check_query], variables: nil } @@ -383,6 +479,31 @@ def do_registration_check_error_test(delivery_method, path) ) 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, + ), + ) + + assert_raises(StandardError) do + ShopifyAPI::Webhooks::Registry.register_all( + session: @session, + ) + end + end end end end