munster 0.4.0 → 0.4.2

Sign up to get free protection for your applications and to get access to all the features.
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