Skip to content

Commit

Permalink
Merge pull request #1268 from Shopify/liz/return-webhook-id
Browse files Browse the repository at this point in the history
Add webhook _id and api_version to webhook handler
  • Loading branch information
lizkenyon committed Jan 26, 2024
2 parents 79ca4e2 + 0aa3f90 commit dd36554
Show file tree
Hide file tree
Showing 9 changed files with 225 additions and 15 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 34 additions & 9 deletions docs/usage/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)
Expand Down
25 changes: 24 additions & 1 deletion lib/shopify_api/webhooks/handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions lib/shopify_api/webhooks/registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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])) }
Expand All @@ -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
Expand Down
15 changes: 13 additions & 2 deletions lib/shopify_api/webhooks/registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
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
2 changes: 2 additions & 0 deletions test/test_helpers/fake_webhook_handler.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
17 changes: 17 additions & 0 deletions test/test_helpers/new_fake_webhook_handler.rb
Original file line number Diff line number Diff line change
@@ -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
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_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 }
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

0 comments on commit dd36554

Please sign in to comment.