munster 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 86e72b8a849981c0e626054ed78660ba288628389933890d9d662daf2f22daa7
4
- data.tar.gz: 62ab2ab22e5805158eb7f9f2d6fd622d03b32db4c985ed20d07d6e21a4b30c89
3
+ metadata.gz: 1cc63f1db75010013d7ad528bfcc3d4aa52c8d4242ee93a5baff4ecbe6673d01
4
+ data.tar.gz: b8458f7196754f2bde9440b37e4fac46f308e713ed21fca28b700c54aa599168
5
5
  SHA512:
6
- metadata.gz: 5436a8a198fac9c1febfbd351bb44435307d88d7d1f87f70f293359ae88e304aaaa6378c0a0c14f89b534344f088ce14bb3afd8fdc323fd99788559c56362013
7
- data.tar.gz: d2f7a667043d29b9bb46f5248963f6e02a6198f012f17ca5f9c9fdbc6c180d6ba0088273942e767b8feece8c1c985fba03183c5eb6a22377bce8853af70102df
6
+ metadata.gz: 4a8ee7017935cb805c69f7cf0f31b88356b00466159040d5f67b763d19643844246485b3db0d8f4302dfd3ab02b9ff862cabf1fb541a0b338e237897e1327723
7
+ data.tar.gz: 6ac65ba57bebe9fce221ae820369b20eb78c69823c021a9d74b58d38122a7ac6e6918ba84e63d16db4b60330684bf358f4ec72c55590afddccfaa3113ce7d841
data/CHANGELOG.md CHANGED
@@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file.
3
3
 
4
4
  This format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## 0.4.2
7
+
8
+ - When processing a webhook, print messages to the ActiveJob logger. This allows for quicker debugging if the app does not have an error tracking service set up
9
+
10
+ ## 0.4.1
11
+
12
+ - Webhook processor now requires `active_job/railtie`, instead of `active_job`. It requires GlobalID to work and that get's required only with railtie.
13
+ - Adding a state transition from `error` to `received`. Proccessing job will be enqueued automatic after this transition was executed.
6
14
 
7
15
  ## 0.4.0
8
16
 
data/README.md CHANGED
@@ -30,6 +30,18 @@ Mount munster engine in your routes.
30
30
  mount Munster::Engine, at: "/webhooks"
31
31
  ```
32
32
 
33
+ Define a class for your first handler (let's call it `ExampleHandler`) and inherit it from `Munster::BaseHandler`. Place it somewhere where Rails autoloading can find it, and add it to your `munster.rb` config file:
34
+
35
+ ```ruby
36
+ config.active_handlers = {
37
+ "example" => "ExampleHandler"
38
+ }
39
+ ```
40
+
41
+ ## Example handlers
42
+
43
+ We provide a number of webhook handlers which demonstrate certain features of Munster. You will find them in `handler-examples`.
44
+
33
45
  ## Requirements
34
46
 
35
47
  This project depends on two dependencies:
@@ -0,0 +1,36 @@
1
+ # This is an example handler for Customer.io reporting webhooks. You
2
+ # can find more documentation here https://customer.io/docs/api/webhooks/#operation/reportingWebhook
3
+ class Webhooks::CustomerIoHandler < Munster::BaseHandler
4
+ def process(webhook)
5
+ json = JSON.parse(webhook.body, symbolize_names: true)
6
+ case json[:metric]
7
+ when "subscribed"
8
+ # ...
9
+ when "unsubscribed"
10
+ # ...
11
+ when "cio_subscription_preferences_changed"
12
+ # ...
13
+ end
14
+ end
15
+
16
+ def extract_event_id_from_request(action_dispatch_request)
17
+ action_dispatch_request.params.fetch(:event_id)
18
+ end
19
+
20
+ # Verify that request is actually comming from customer.io here
21
+ # @see https://customer.io/docs/api/webhooks/#section/Securely-Verifying-Requests
22
+ #
23
+ # - Should have "X-CIO-Signature", "X-CIO-Timestamp" headers.
24
+ # - Combine the version number, timestamp and body delimited by colons to form a string in the form v0:<timestamp>:<body>
25
+ # - Using HMAC-SHA256, hash the string using your webhook signing secret as the hash key.
26
+ # - Compare this value to the value of the X-CIO-Signature header sent with the request to confirm
27
+ def valid?(action_dispatch_request)
28
+ signing_key = Rails.application.secrets.customer_io_webhook_signing_key
29
+ xcio_signature = action_dispatch_request.headers["HTTP_X_CIO_SIGNATURE"]
30
+ xcio_timestamp = action_dispatch_request.headers["HTTP_X_CIO_TIMESTAMP"]
31
+ request_body = action_dispatch_request.body.read
32
+ string_to_sign = "v0:#{xcio_timestamp}:#{request_body}"
33
+ hmac = OpenSSL::HMAC.hexdigest("SHA256", signing_key, string_to_sign)
34
+ Rack::Utils.secure_compare(hmac, xcio_signature)
35
+ end
36
+ end
@@ -0,0 +1,29 @@
1
+ # This is for Revolut V1 API for webhooks - https://developer.revolut.com/docs/business/webhooks-v-1-deprecated
2
+ class RevolutBusinessV1Handler < Munster::BaseHandler
3
+ def valid?(_)
4
+ # V1 of Revolut webhooks does not support signatures
5
+ true
6
+ end
7
+
8
+ def self.process(webhook)
9
+ parsed_payload = JSON.parse(webhook.body)
10
+ topic = parsed_payload.fetch("Topic")
11
+ case topic
12
+ when "tokens" # Account access revocation payload
13
+ # ...
14
+ when "draftpayments/transfers" # Draft payment transfer notification payload
15
+ # ...
16
+ else
17
+ # ...
18
+ end
19
+ end
20
+
21
+ def self.extract_event_id_from_request(action_dispatch_request)
22
+ # Since b-tree indices generally divide from the start of the string, place the highest
23
+ # entropy component at the start (the EventId)
24
+ key_components = %w[EventId Topic Version]
25
+ key_components.map do |key|
26
+ action_dispatch_request.params.fetch(key)
27
+ end.join("-")
28
+ end
29
+ end
@@ -0,0 +1,42 @@
1
+ # This is for Revolut V2 API for webhooks - https://developer.revolut.com/docs/business/webhooks-v-2
2
+ class RevolutBusinessV2Handler < Munster::BaseHandler
3
+ def valid?(request)
4
+ # 1 - Validate the timestamp of the request. Prevent replay attacks.
5
+ # "To validate the event, make sure that the Revolut-Request-Timestamp date-time is within a 5-minute time tolerance of the current universal time (UTC)".
6
+ # Their examples list `timestamp = '1683650202360'` as a sample value, so their timestamp is in millis - not in seconds
7
+ timestamp_str_from_headers = request.headers["HTTP_REVOLUT_REQUEST_TIMESTAMP"]
8
+ delta_t_seconds = (timestamp_str_from_headers / 1000) - Time.now.to_i
9
+ return false unless delta_t_seconds.abs < (5 * 60)
10
+
11
+ # 2 - Validate the signature
12
+ # https://developer.revolut.com/docs/guides/manage-accounts/tutorials/work-with-webhooks/verify-the-payload-signature
13
+ string_to_sign = [
14
+ "v1",
15
+ timestamp_str_from_headers,
16
+ request.body.read
17
+ ].join(".")
18
+ computed_signature = "v1=" + OpenSSL::HMAC.hexdigest("SHA256", Rails.application.secrets.revolut_business_webhook_signing_key, string_to_sign)
19
+ # Note: "This means that in the period when multiple signing secrets remain valid, multiple signatures are sent."
20
+ # https://developer.revolut.com/docs/guides/manage-accounts/tutorials/work-with-webhooks/manage-webhooks#rotate-a-webhook-signing-secret
21
+ # https://developer.revolut.com/docs/guides/manage-accounts/tutorials/work-with-webhooks/about-webhooks#security
22
+ # An HTTP header may contain multiple values if it gets sent multiple times. But it does mean we need to test for multiple provided
23
+ # signatures in case of rotation.
24
+ provided_signatures = request.headers["HTTP_REVOLUT_SIGNATURE"].split(",")
25
+ # Use #select instead of `find` to compare all signatures even if only one matches - this to avoid timing leaks.
26
+ # Small effort but might be useful.
27
+ matches = provided_signatures.select do |provided_signature|
28
+ ActiveSupport::SecurityUtils.secure_compare(provided_signature, computed_signature)
29
+ end
30
+ matches.any?
31
+ end
32
+
33
+ def self.process(webhook)
34
+ Rails.logger.info { "Processing Revolut webhook #{webhook.body.inspect}" }
35
+ end
36
+
37
+ def self.extract_event_id_from_request(action_dispatch_request)
38
+ # The event ID is only available when you retrieve the failed webhooks, which is sad.
39
+ # We can divinate a synthetic ID though by taking a hash of the entire payload though.
40
+ Digest::SHA256.hexdigest(action_dispatch_request.body.read)
41
+ end
42
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This handler is an example for Starling Payments API,
4
+ # you can find the documentation here https://developer.starlingbank.com/payments/docs#account-and-address-structure-1
5
+ class StarlingPaymentsHandler < Munster::BaseHandler
6
+ # This method will be used to process webhook by async worker.
7
+ def process(received_webhook)
8
+ Rails.logger.info { received_webhook.body }
9
+ end
10
+
11
+ # Starling supplies signatures in the form SHA512(secret + request_body)
12
+ def valid?(action_dispatch_request)
13
+ supplied_signature = action_dispatch_request.headers.fetch("X-Hook-Signature")
14
+ supplied_digest_bytes = Base64.strict_decode64(supplied_signature)
15
+ sha512 = Digest::SHA2.new(512)
16
+ signing_secret = Rails.credentials.starling_payments_webhook_signing_secret
17
+ computed_digest_bytes = sha512.digest(signing_secret.b + action_dispatch_request.body.b)
18
+ ActiveSupport::SecurityUtils.secure_compare(computed_digest_bytes, supplied_digest_bytes)
19
+ end
20
+
21
+ # Some Starling webhooks do not provide a notification UID, but for those which do we can deduplicate
22
+ def extract_event_id_from_request(action_dispatch_request)
23
+ action_dispatch_request.params.fetch("notificationUid", SecureRandom.uuid)
24
+ end
25
+ end
@@ -16,11 +16,27 @@ module Munster
16
16
  webhook = Munster::ReceivedWebhook.new(request: action_dispatch_request, handler_event_id: handler_event_id, handler_module_name: handler_module_name)
17
17
  webhook.save!
18
18
 
19
- Munster.configuration.processing_job_class.perform_later(webhook)
19
+ enqueue(webhook)
20
20
  rescue ActiveRecord::RecordNotUnique # Webhook deduplicated
21
21
  Rails.logger.info { "#{inspect} Webhook #{handler_event_id} is a duplicate delivery and will not be stored." }
22
22
  end
23
23
 
24
+ # Enqueues the processing job to process webhook asynchronously. The job class could be configured.
25
+ #
26
+ # @param webhook [Munster::ReceivedWebhook]
27
+ # @return [void]
28
+ def enqueue(webhook)
29
+ # The configured job class can be a class name or a module, to support lazy loading
30
+ job_class_or_module_name = Munster.configuration.processing_job_class
31
+ job_class = if job_class_or_module_name.respond_to?(:perform_later)
32
+ job_class_or_module_name
33
+ else
34
+ job_class_or_module_name.constantize
35
+ end
36
+
37
+ job_class.perform_later(webhook)
38
+ end
39
+
24
40
  # This is the heart of your webhook processing. Override this method and define your processing inside of it.
25
41
  # The `received_webhook` will provide access to the `ReceivedWebhook` model, which contains the received
26
42
  # body of the webhook request, but also the full (as-full-as-possible) clone of the original ActionDispatch::Request
@@ -1,26 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_job" if defined?(Rails)
3
+ require "active_job/railtie"
4
4
 
5
5
  module Munster
6
6
  class ProcessingJob < ActiveJob::Base
7
- class WebhookPayloadInvalid < StandardError
8
- end
9
-
10
7
  def perform(webhook)
11
8
  Rails.error.set_context(munster_handler_module_name: webhook.handler_module_name, **Munster.configuration.error_context)
12
9
 
10
+ webhook_details_for_logs = "Munster::ReceivedWebhook#%s (handler: %s)" % [webhook.id, webhook.handler]
13
11
  webhook.with_lock do
14
- return unless webhook.received?
12
+ unless webhook.received?
13
+ logger.info { "#{webhook_details_for_logs} is being processed in a different job or has been processed already, skipping." }
14
+ return
15
+ end
15
16
  webhook.processing!
16
17
  end
17
18
 
18
19
  if webhook.handler.valid?(webhook.request)
20
+ logger.info { "#{webhook_details_for_logs} starting to process" }
19
21
  webhook.handler.process(webhook)
20
22
  webhook.processed! if webhook.processing?
23
+ logger.info { "#{webhook_details_for_logs} processed" }
21
24
  else
22
- e = WebhookPayloadInvalid.new("#{webhook.class} #{webhook.id} did not pass validation and was skipped")
23
- Rails.error.report(e, handled: true, severity: :error)
25
+ logger.info { "#{webhook_details_for_logs} did not pass validation by the handler. Marking it `failed_validation`." }
24
26
  webhook.failed_validation!
25
27
  end
26
28
  rescue => e
@@ -15,6 +15,11 @@ module Munster
15
15
  s.permit_transition(:processing, :skipped)
16
16
  s.permit_transition(:processing, :processed)
17
17
  s.permit_transition(:processing, :error)
18
+ s.permit_transition(:error, :received)
19
+
20
+ s.after_committed_transition_to(:received) do |webhook|
21
+ webhook.handler.enqueue(webhook)
22
+ end
18
23
  end
19
24
 
20
25
  # Store the pertinent data from an ActionDispatch::Request into the webhook.
@@ -1,10 +1,15 @@
1
1
  Munster.configure do |config|
2
2
  # Active Handlers are defined as hash with key as a service_id and handler class that would handle webhook request.
3
+ # A Handler must respond to `.new` and return an object roughly matching `Munster::BaseHandler` in terms of interface.
4
+ # Use module names (strings) here to allow the handler modules to be lazy-loaded by Rails.
5
+ #
3
6
  # Example:
4
- # {:test => TestHandler, :inactive => InactiveHandler}
7
+ # {:test => "TestHandler", :inactive => "InactiveHandler"}
5
8
  config.active_handlers = {}
6
9
 
7
- # It's possible to overwrite default processing job to enahance it. As example if you want to add proper locking or retry mechanism.
10
+ # It's possible to overwrite default processing job to enahance it. As example if you want to add custom
11
+ # locking or retry mechanism. You want to inherit that job from Munster::ProcessingJob because the background
12
+ # job also manages the webhook state.
8
13
  #
9
14
  # Example:
10
15
  #
@@ -15,9 +20,9 @@ Munster.configure do |config|
15
20
  # end
16
21
  # end
17
22
  #
18
- # This is how you can change processing job:
23
+ # In the config a string with your job' class name can be used so that the job can be lazy-loaded by Rails:
19
24
  #
20
- # config.processing_job_class = WebhookProcessingJob
25
+ # config.processing_job_class = "WebhookProcessingJob"
21
26
 
22
27
  # We're using a common interface for error reporting provided by Rails, e.g Rails.error.report. In some cases
23
28
  # you want to enhance those errors with additional context. As example to provide a namespace:
@@ -25,4 +30,11 @@ Munster.configure do |config|
25
30
  # { appsignal: { namespace: "webhooks" } }
26
31
  #
27
32
  # config.error_context = { appsignal: { namespace: "webhooks" } }
33
+
34
+ # Incoming webhooks will be written into your DB without any prior validation. By default, Munster limits the
35
+ # request body size for webhooks to 512 KiB, so that it would not be too easy for an attacker to fill your
36
+ # database with junk. However, if you are receiving very large webhook payloads you might need to increase
37
+ # that limit (or make it even smaller for extra security)
38
+ #
39
+ # config.request_body_size_limit = 2.megabytes
28
40
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Munster
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.2"
5
5
  end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require_relative "test_app"
5
+
6
+ class TestMunster < ActionDispatch::IntegrationTest
7
+ teardown { Munster::ReceivedWebhook.delete_all }
8
+
9
+ def test_that_it_has_a_version_number
10
+ refute_nil ::Munster::VERSION
11
+ end
12
+
13
+ def webhook_body
14
+ <<~JSON
15
+ {
16
+ "provider_id": "musterbank-flyio",
17
+ "starts_at": "<%= Time.now.utc %>",
18
+ "external_source": "The Forge Of Downtime",
19
+ "external_ticket_title": "DOWN-123",
20
+ "internal_description_markdown": "A test has failed"
21
+ }
22
+ JSON
23
+ end
24
+
25
+ Munster.configure do |config|
26
+ config.active_handlers = {
27
+ test: WebhookTestHandler,
28
+ inactive: "InactiveHandler",
29
+ invalid: "InvalidHandler",
30
+ private: "PrivateHandler",
31
+ "failing-with-exposed-errors": "FailingWithExposedErrors",
32
+ "failing-with-concealed-errors": "FailingWithConcealedErrors",
33
+ extract_id: "ExtractIdHandler"
34
+ }
35
+ end
36
+ self.app = MunsterTestApp
37
+
38
+ def self.xtest(msg)
39
+ test(msg) { skip }
40
+ end
41
+
42
+ test "ensure webhook is processed only once during creation" do
43
+ tf = Tempfile.new
44
+ body = {isValid: true, outputToFilename: tf.path}
45
+ body_json = body.to_json
46
+
47
+ assert_enqueued_jobs 1, only: Munster::ProcessingJob do
48
+ post "/munster/test", params: body_json, headers: {"CONTENT_TYPE" => "application/json"}
49
+ assert_response 200
50
+ end
51
+ end
52
+
53
+ test "accepts a webhook, stores and processes it" do
54
+ tf = Tempfile.new
55
+ body = {isValid: true, outputToFilename: tf.path}
56
+ body_json = body.to_json
57
+
58
+ post "/munster/test", params: body_json, headers: {"CONTENT_TYPE" => "application/json"}
59
+ assert_response 200
60
+
61
+ webhook = Munster::ReceivedWebhook.last!
62
+
63
+ assert_predicate webhook, :received?
64
+ assert_equal "WebhookTestHandler", webhook.handler_module_name
65
+ assert_equal webhook.status, "received"
66
+ assert_equal webhook.body, body_json
67
+
68
+ perform_enqueued_jobs
69
+ assert_predicate webhook.reload, :processed?
70
+ tf.rewind
71
+ assert_equal tf.read, body_json
72
+ end
73
+
74
+ test "accepts a webhook but does not process it if it is invalid" do
75
+ tf = Tempfile.new
76
+ body = {isValid: false, outputToFilename: tf.path}
77
+ body_json = body.to_json
78
+
79
+ post "/munster/test", params: body_json, headers: {"CONTENT_TYPE" => "application/json"}
80
+ assert_response 200
81
+
82
+ webhook = Munster::ReceivedWebhook.last!
83
+
84
+ assert_predicate webhook, :received?
85
+ assert_equal "WebhookTestHandler", webhook.handler_module_name
86
+ assert_equal webhook.status, "received"
87
+ assert_equal webhook.body, body_json
88
+
89
+ perform_enqueued_jobs
90
+ assert_predicate webhook.reload, :failed_validation?
91
+
92
+ tf.rewind
93
+ assert_predicate tf.read, :empty?
94
+ end
95
+
96
+ test "marks a webhook as errored if it raises during processing" do
97
+ tf = Tempfile.new
98
+ body = {isValid: true, raiseDuringProcessing: true, outputToFilename: tf.path}
99
+ body_json = body.to_json
100
+
101
+ post "/munster/test", params: body_json, headers: {"CONTENT_TYPE" => "application/json"}
102
+ assert_response 200
103
+
104
+ webhook = Munster::ReceivedWebhook.last!
105
+
106
+ assert_predicate webhook, :received?
107
+ assert_equal "WebhookTestHandler", webhook.handler_module_name
108
+ assert_equal webhook.status, "received"
109
+ assert_equal webhook.body, body_json
110
+
111
+ assert_raises(StandardError) { perform_enqueued_jobs }
112
+ assert_predicate webhook.reload, :error?
113
+
114
+ tf.rewind
115
+ assert_predicate tf.read, :empty?
116
+ end
117
+
118
+ test "does not accept a test payload that is larger than the configured maximum size" do
119
+ oversize = Munster.configuration.request_body_size_limit + 1
120
+ utf8_junk = Base64.strict_encode64(Random.bytes(oversize))
121
+ body = {isValid: true, filler: utf8_junk, raiseDuringProcessing: false, outputToFilename: "/tmp/nothing"}
122
+ body_json = body.to_json
123
+
124
+ post "/munster/test", params: body_json, headers: {"CONTENT_TYPE" => "application/json"}
125
+ assert_raises(ActiveRecord::RecordNotFound) { Munster::ReceivedWebhook.last! }
126
+ end
127
+
128
+ test "does not try to process a webhook if it is not in `received' state" do
129
+ tf = Tempfile.new
130
+ body = {isValid: true, raiseDuringProcessing: true, outputToFilename: tf.path}
131
+ body_json = body.to_json
132
+
133
+ post "/munster/test", params: body_json, headers: {"CONTENT_TYPE" => "application/json"}
134
+ assert_response 200
135
+
136
+ webhook = Munster::ReceivedWebhook.last!
137
+ webhook.processing!
138
+
139
+ perform_enqueued_jobs
140
+ assert_predicate webhook.reload, :processing?
141
+
142
+ tf.rewind
143
+ assert_predicate tf.read, :empty?
144
+ end
145
+
146
+ test "raises an error if the service_id is not known" do
147
+ post "/munster/missing_service", params: webhook_body, headers: {"CONTENT_TYPE" => "application/json"}
148
+ assert_response 404
149
+ end
150
+
151
+ test "returns a 503 when a handler is inactive" do
152
+ post "/munster/inactive", params: webhook_body, headers: {"CONTENT_TYPE" => "application/json"}
153
+
154
+ assert_response 503
155
+ assert_equal 'Webhook handler "inactive" is inactive', response.parsed_body["error"]
156
+ end
157
+
158
+ test "returns a 200 status and error message if the handler does not expose errors" do
159
+ post "/munster/failing-with-concealed-errors", params: webhook_body, headers: {"CONTENT_TYPE" => "application/json"}
160
+
161
+ assert_response 200
162
+ assert_equal false, response.parsed_body["ok"]
163
+ assert response.parsed_body["error"]
164
+ end
165
+
166
+ test "returns a 500 status and error message if the handler does not expose errors" do
167
+ post "/munster/failing-with-exposed-errors", params: webhook_body, headers: {"CONTENT_TYPE" => "application/json"}
168
+
169
+ assert_response 500
170
+ # The response generation in this case is done by Rails, through the
171
+ # common Rails error page
172
+ end
173
+
174
+ test "deduplicates received webhooks based on the event ID" do
175
+ body = {event_id: SecureRandom.uuid, body: "test"}.to_json
176
+
177
+ assert_changes_by -> { Munster::ReceivedWebhook.count }, exactly: 1 do
178
+ 3.times do
179
+ post "/munster/extract_id", params: body, headers: {"CONTENT_TYPE" => "application/json"}
180
+ assert_response 200
181
+ end
182
+ end
183
+ end
184
+
185
+ test "preserves the route params and the request params in the serialised request stored with the webhook" do
186
+ body = {user_name: "John", number_of_dependents: 14}.to_json
187
+
188
+ Munster::ReceivedWebhook.delete_all
189
+ post "/per-user-munster/123/private", params: body, headers: {"CONTENT_TYPE" => "application/json"}
190
+ assert_response 200
191
+
192
+ received_webhook = Munster::ReceivedWebhook.first!
193
+ assert_predicate received_webhook, :received?
194
+ assert_equal body, received_webhook.request.body.read
195
+ assert_equal "John", received_webhook.request.params["user_name"]
196
+ assert_equal 14, received_webhook.request.params["number_of_dependents"]
197
+ assert_equal "123", received_webhook.request.params["user_id"]
198
+ end
199
+
200
+ test "erroneous webhook could be processed again" do
201
+ webhook = Munster::ReceivedWebhook.create(
202
+ handler_event_id: "test",
203
+ handler_module_name: "WebhookTestHandler",
204
+ status: "error",
205
+ body: {isValid: true}.to_json
206
+ )
207
+
208
+ assert_enqueued_jobs 1, only: Munster::ProcessingJob do
209
+ webhook.received!
210
+
211
+ assert_equal "received", webhook.status
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,5 @@
1
+ class ExtractIdHandler < WebhookTestHandler
2
+ def extract_event_id_from_request(action_dispatch_request)
3
+ JSON.parse(action_dispatch_request.body.read).fetch("event_id")
4
+ end
5
+ end
@@ -0,0 +1,7 @@
1
+ class FailingWithConcealedErrors < Munster::BaseHandler
2
+ def handle(_request)
3
+ raise "oops"
4
+ end
5
+
6
+ def expose_errors_to_sender? = false
7
+ end
@@ -0,0 +1,7 @@
1
+ class FailingWithExposedErrors < Munster::BaseHandler
2
+ def handle(_request)
3
+ raise "oops"
4
+ end
5
+
6
+ def expose_errors_to_sender? = true
7
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class InactiveHandler < WebhookTestHandler
4
+ def active? = false
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class InvalidHandler < WebhookTestHandler
4
+ def valid?(request) = false
5
+ end
@@ -0,0 +1,3 @@
1
+ class PrivateHandler < WebhookTestHandler
2
+ def expose_errors_to_sender? = false
3
+ end
@@ -0,0 +1,13 @@
1
+ class WebhookTestHandler < Munster::BaseHandler
2
+ def valid?(request)
3
+ request.params.fetch(:isValid, false)
4
+ end
5
+
6
+ def process(webhook)
7
+ raise "Oops, failed" if webhook.request.params[:raiseDuringProcessing]
8
+ filename = webhook.request.params.fetch(:outputToFilename)
9
+ File.binwrite(filename, webhook.body)
10
+ end
11
+
12
+ def expose_errors_to_sender? = true
13
+ end
data/test/test_app.rb ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "action_pack"
5
+ require "action_controller"
6
+ require "rails"
7
+
8
+ database = "development.sqlite3"
9
+ ENV["DATABASE_URL"] = "sqlite3:#{database}"
10
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: database)
11
+ ActiveRecord::Base.logger = Logger.new(nil)
12
+ ActiveRecord::Schema.define do
13
+ create_table "received_webhooks", force: :cascade do |t|
14
+ t.string "handler_event_id", null: false
15
+ t.string "handler_module_name", null: false
16
+ t.string "status", default: "received", null: false
17
+ t.binary "body", null: false
18
+ t.json "request_headers", null: true
19
+ t.datetime "created_at", null: false
20
+ t.datetime "updated_at", null: false
21
+ t.index ["handler_module_name", "handler_event_id"], name: "webhook_dedup_idx", unique: true
22
+ t.index ["status"], name: "index_received_webhooks_on_status"
23
+ end
24
+ end
25
+
26
+ require_relative "../lib/munster"
27
+ require_relative "test-webhook-handlers/webhook_test_handler"
28
+
29
+ class MunsterTestApp < Rails::Application
30
+ config.logger = Logger.new(nil)
31
+ config.autoload_paths << File.dirname(__FILE__) + "/test-webhook-handlers"
32
+ config.root = __dir__
33
+ config.eager_load = false
34
+ config.consider_all_requests_local = true
35
+ config.secret_key_base = "i_am_a_secret"
36
+ config.active_support.cache_format_version = 7.0
37
+ config.hosts << ->(host) { true } # Permit all hosts
38
+
39
+ routes.append do
40
+ mount Munster::Engine, at: "/munster"
41
+ post "/per-user-munster/:user_id/:service_id" => "munster/receive_webhooks#create"
42
+ end
43
+ end
44
+
45
+ MunsterTestApp.initialize!
46
+
47
+ # run MunsterTestApp
@@ -0,0 +1,50 @@
1
+ require_relative "test_app"
2
+ require "rails/test_help"
3
+
4
+ class ActiveSupport::TestCase
5
+ # Same as "assert_changes" in Rails but for countable entities.
6
+ # @return [*] return value of the block
7
+ # @example
8
+ # assert_changes_by("Notification.count", exactly: 2) do
9
+ # cause_two_notifications_to_get_delivered
10
+ # end
11
+ def assert_changes_by(expression, message = nil, exactly: nil, at_least: nil, at_most: nil, &block)
12
+ # rubocop:disable Security/Eval
13
+ exp = expression.respond_to?(:call) ? expression : -> { eval(expression.to_s, block.binding) }
14
+ # rubocop:enable Security/Eval
15
+
16
+ raise "either exactly:, at_least: or at_most: must be specified" unless exactly || at_least || at_most
17
+ raise "exactly: is mutually exclusive with other options" if exactly && (at_least || at_most)
18
+ raise "at_most: must be larger than at_least:" if at_least && at_most && at_most < at_least
19
+
20
+ before = exp.call
21
+ retval = assert_nothing_raised(&block)
22
+
23
+ after = exp.call
24
+ delta = after - before
25
+
26
+ if exactly
27
+ at_most = exactly
28
+ at_least = exactly
29
+ end
30
+
31
+ # We do not make these an if/else since we allow both at_most and at_least
32
+ if at_most
33
+ error = "#{expression.inspect} changed by #{delta} which is more than #{at_most}"
34
+ error = "#{error}. It was #{before} and became #{after}"
35
+ error = "#{message.call}.\n" if message&.respond_to?(:call)
36
+ error = "#{message}.\n#{error}" if message && !message.respond_to?(:call)
37
+ assert delta <= at_most, error
38
+ end
39
+
40
+ if at_least
41
+ error = "#{expression.inspect} changed by #{delta} which is less than #{at_least}"
42
+ error = "#{error}. It was #{before} and became #{after}"
43
+ error = "#{message.call}.\n" if message&.respond_to?(:call)
44
+ error = "#{message}.\n#{error}" if message && !message.respond_to?(:call)
45
+ assert delta >= at_least, error
46
+ end
47
+
48
+ retval
49
+ end
50
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: munster
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stanislav Katkov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-07-24 00:00:00.000000000 Z
11
+ date: 2024-08-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -170,6 +170,10 @@ files:
170
170
  - example/tmp/.keep
171
171
  - example/tmp/pids/.keep
172
172
  - example/vendor/.keep
173
+ - handler-examples/customer_io_handler.rb
174
+ - handler-examples/revolut_business_v1_handler.rb
175
+ - handler-examples/revolut_business_v2_handler.rb
176
+ - handler-examples/starling_payments_handler.rb
173
177
  - lib/munster.rb
174
178
  - lib/munster/base_handler.rb
175
179
  - lib/munster/controllers/receive_webhooks_controller.rb
@@ -182,6 +186,17 @@ files:
182
186
  - lib/munster/templates/munster.rb
183
187
  - lib/munster/version.rb
184
188
  - lib/tasks/munster_tasks.rake
189
+ - test/munster_test.rb
190
+ - test/test-webhook-handlers/.DS_Store
191
+ - test/test-webhook-handlers/extract_id_handler.rb
192
+ - test/test-webhook-handlers/failing_with_concealed_errors.rb
193
+ - test/test-webhook-handlers/failing_with_exposed_errors.rb
194
+ - test/test-webhook-handlers/inactive_handler.rb
195
+ - test/test-webhook-handlers/invalid_handler.rb
196
+ - test/test-webhook-handlers/private_handler.rb
197
+ - test/test-webhook-handlers/webhook_test_handler.rb
198
+ - test/test_app.rb
199
+ - test/test_helper.rb
185
200
  homepage: https://www.cheddar.me/
186
201
  licenses:
187
202
  - MIT
@@ -204,7 +219,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
204
219
  - !ruby/object:Gem::Version
205
220
  version: '0'
206
221
  requirements: []
207
- rubygems_version: 3.5.9
222
+ rubygems_version: 3.3.7
208
223
  signing_key:
209
224
  specification_version: 4
210
225
  summary: Webhooks processing engine for Rails applications