Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add webhook _id and api_version to webhook handler #1268

Merged
merged 9 commits into from
Jan 26, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions docs/usage/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
lizkenyon marked this conversation as resolved.
Show resolved Hide resolved
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
Expand All @@ -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
)
Expand Down
21 changes: 19 additions & 2 deletions lib/shopify_api/webhooks/handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,29 @@
module ShopifyAPI
module Webhooks
module Handler
include Kernel
extend T::Sig
extend T::Helpers
interface!
abstract!

sig { abstract.params(topic: String, shop: String, body: T::Hash[String, T.untyped]).void }
sig do
params(data: T::Hash[Symbol, T.untyped]).void
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we:

  • explicitly type data?
  • make data an object instead of a hash to keep the API similar to what it was before? Can we use ShopifyAPI::Webhooks::Request?

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?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this name is clear enough - is there something we can name it that indicates what it does (i.e. that it takes in the metadata object, or the full metadata)?

We could even take it a bit further: if we need a new method, we could simply test for the presence of that method in the class when we're calling, so use_handle_webhook? wouldn't be necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could even take it a bit further: if we need a new method, we could simply test for the presence of that method in the class when we're calling, so use_handle_webhook? wouldn't be necessary.

This is what I originally tried and a couldn't get this to work, do you know of a way?
This is what I did:
Originally this was an interface file meaning meaning if we add a new method users would need to implement the new method or they get an error (breaking change). So I changed it to an abstract file with a default implementation so users would not have to implement the new method. But then you loose the ability to check with respond_to? because there is and implementation so it always returns true.

Copy link
Contributor

@paulomarg paulomarg Jan 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm right - I guess we can't have an interface with an optional method (unless we create a wholly different interface), which complicates things indeed. Let's go ahead with this approach then.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed, I refactored the handler to have multiple handler modules.
Developers can implement the new handler if they want they additional data.

false
end
end
end
end
11 changes: 10 additions & 1 deletion lib/shopify_api/webhooks/registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +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)
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)
lizkenyon marked this conversation as resolved.
Show resolved Hide resolved
DEPRECATED: Use ShopifyAPI::Webhooks::Handler#handle_webhook
instead of ShopifyAPI::Webhooks::Handler#handle
lizkenyon marked this conversation as resolved.
Show resolved Hide resolved
WARNING
end
end

private
Expand Down
10 changes: 10 additions & 0 deletions lib/shopify_api/webhooks/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions test/test_helpers/new_fake_webhook_handler.rb
Original file line number Diff line number Diff line change
@@ -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
123 changes: 122 additions & 1 deletion test/webhooks/registry_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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",
Expand Down Expand Up @@ -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_test_new_handler(delivery_method, path, fields: nil, metafield_namespaces: nil)
lizkenyon marked this conversation as resolved.
Show resolved Hide resolved
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 }
Expand All @@ -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
Loading