webhukhs 0.5.0

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.
Files changed (105) hide show
  1. checksums.yaml +7 -0
  2. data/.editorconfig +14 -0
  3. data/.rubocop.yml +9 -0
  4. data/.standard.yml +3 -0
  5. data/Appraisals +13 -0
  6. data/CHANGELOG.md +54 -0
  7. data/LICENSE +21 -0
  8. data/README.md +71 -0
  9. data/Rakefile +26 -0
  10. data/config/routes.rb +3 -0
  11. data/example/.gitattributes +7 -0
  12. data/example/.gitignore +29 -0
  13. data/example/.ruby-version +1 -0
  14. data/example/Gemfile +32 -0
  15. data/example/Gemfile.lock +228 -0
  16. data/example/README.md +24 -0
  17. data/example/Rakefile +8 -0
  18. data/example/app/assets/images/.keep +0 -0
  19. data/example/app/assets/stylesheets/application.css +1 -0
  20. data/example/app/controllers/application_controller.rb +4 -0
  21. data/example/app/controllers/concerns/.keep +0 -0
  22. data/example/app/helpers/application_helper.rb +4 -0
  23. data/example/app/models/application_record.rb +5 -0
  24. data/example/app/models/concerns/.keep +0 -0
  25. data/example/app/views/layouts/application.html.erb +15 -0
  26. data/example/app/webhooks/webhook_test_handler.rb +13 -0
  27. data/example/bin/bundle +109 -0
  28. data/example/bin/rails +4 -0
  29. data/example/bin/rake +4 -0
  30. data/example/bin/setup +33 -0
  31. data/example/config/application.rb +39 -0
  32. data/example/config/boot.rb +5 -0
  33. data/example/config/credentials.yml.enc +1 -0
  34. data/example/config/database.yml +25 -0
  35. data/example/config/environment.rb +7 -0
  36. data/example/config/environments/development.rb +61 -0
  37. data/example/config/environments/production.rb +71 -0
  38. data/example/config/environments/test.rb +52 -0
  39. data/example/config/initializers/assets.rb +14 -0
  40. data/example/config/initializers/content_security_policy.rb +27 -0
  41. data/example/config/initializers/filter_parameter_logging.rb +10 -0
  42. data/example/config/initializers/generators.rb +7 -0
  43. data/example/config/initializers/inflections.rb +18 -0
  44. data/example/config/initializers/permissions_policy.rb +13 -0
  45. data/example/config/initializers/webhukhs.rb +9 -0
  46. data/example/config/locales/en.yml +33 -0
  47. data/example/config/puma.rb +45 -0
  48. data/example/config/routes.rb +5 -0
  49. data/example/config.ru +8 -0
  50. data/example/db/migrate/20240523125859_create_webhukhs_tables.rb +22 -0
  51. data/example/db/schema.rb +24 -0
  52. data/example/db/seeds.rb +9 -0
  53. data/example/lib/assets/.keep +0 -0
  54. data/example/lib/tasks/.keep +0 -0
  55. data/example/log/.keep +0 -0
  56. data/example/public/404.html +67 -0
  57. data/example/public/422.html +67 -0
  58. data/example/public/500.html +66 -0
  59. data/example/public/apple-touch-icon-precomposed.png +0 -0
  60. data/example/public/apple-touch-icon.png +0 -0
  61. data/example/public/favicon.ico +0 -0
  62. data/example/public/robots.txt +1 -0
  63. data/example/test/controllers/.keep +0 -0
  64. data/example/test/fixtures/files/.keep +0 -0
  65. data/example/test/helpers/.keep +0 -0
  66. data/example/test/integration/.keep +0 -0
  67. data/example/test/models/.keep +0 -0
  68. data/example/test/test_helper.rb +15 -0
  69. data/example/tmp/.keep +0 -0
  70. data/example/tmp/pids/.keep +0 -0
  71. data/example/vendor/.keep +0 -0
  72. data/gemfiles/rails_7.1.gemfile +10 -0
  73. data/gemfiles/rails_7.1.gemfile.lock +412 -0
  74. data/gemfiles/rails_8.0.gemfile +10 -0
  75. data/gemfiles/rails_8.0.gemfile.lock +405 -0
  76. data/gemfiles/rails_8.1.gemfile +10 -0
  77. data/gemfiles/rails_8.1.gemfile.lock +405 -0
  78. data/handler-examples/customer_io_handler.rb +36 -0
  79. data/handler-examples/revolut_business_v1_handler.rb +29 -0
  80. data/handler-examples/revolut_business_v2_handler.rb +42 -0
  81. data/handler-examples/starling_payments_handler.rb +25 -0
  82. data/lib/tasks/webhukhs_tasks.rake +4 -0
  83. data/lib/webhukhs/base_handler.rb +100 -0
  84. data/lib/webhukhs/controllers/receive_webhooks_controller.rb +55 -0
  85. data/lib/webhukhs/engine.rb +24 -0
  86. data/lib/webhukhs/install_generator.rb +31 -0
  87. data/lib/webhukhs/jobs/processing_job.rb +33 -0
  88. data/lib/webhukhs/models/received_webhook.rb +99 -0
  89. data/lib/webhukhs/templates/add_headers_to_webhukhs_webhooks.rb.erb +5 -0
  90. data/lib/webhukhs/templates/create_webhukhs_tables.rb.erb +23 -0
  91. data/lib/webhukhs/templates/webhukhs.rb +40 -0
  92. data/lib/webhukhs/version.rb +5 -0
  93. data/lib/webhukhs.rb +23 -0
  94. data/test/test-webhook-handlers/.DS_Store +0 -0
  95. data/test/test-webhook-handlers/extract_id_handler.rb +5 -0
  96. data/test/test-webhook-handlers/failing_with_concealed_errors.rb +7 -0
  97. data/test/test-webhook-handlers/failing_with_exposed_errors.rb +7 -0
  98. data/test/test-webhook-handlers/inactive_handler.rb +5 -0
  99. data/test/test-webhook-handlers/invalid_handler.rb +5 -0
  100. data/test/test-webhook-handlers/private_handler.rb +3 -0
  101. data/test/test-webhook-handlers/webhook_test_handler.rb +13 -0
  102. data/test/test_app.rb +66 -0
  103. data/test/test_helper.rb +50 -0
  104. data/test/webhukhs_test.rb +203 -0
  105. metadata +247 -0
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webhukhs
4
+ class ReceiveWebhooksController < ActionController::API
5
+ class HandlerInactive < StandardError
6
+ end
7
+
8
+ class UnknownHandler < StandardError
9
+ end
10
+
11
+ def create
12
+ Rails.error.set_context(**Webhukhs.configuration.error_context)
13
+ handler = lookup_handler(service_id)
14
+ raise HandlerInactive unless handler.active?
15
+ handler.handle(request)
16
+ render(json: {ok: true, error: nil})
17
+ rescue UnknownHandler => e
18
+ Rails.error.report(e, handled: true, severity: :error)
19
+ render_error_with_status("No handler found for #{service_id.inspect}", status: :not_found)
20
+ rescue HandlerInactive => e
21
+ Rails.error.report(e, handled: true, severity: :error)
22
+ render_error_with_status("Webhook handler #{service_id.inspect} is inactive", status: :service_unavailable)
23
+ rescue => e
24
+ raise e unless handler
25
+ raise e if handler.expose_errors_to_sender?
26
+ Rails.error.report(e, handled: true, severity: :error)
27
+ render_error_with_status("Internal error (#{e})")
28
+ end
29
+
30
+ def service_id
31
+ params.require(:service_id)
32
+ end
33
+
34
+ def render_error_with_status(message_str, status: :ok)
35
+ json = {ok: false, error: message_str}.to_json
36
+ render(json: json, status: status)
37
+ end
38
+
39
+ def lookup_handler(service_id_str)
40
+ active_handlers = Webhukhs.configuration.active_handlers.with_indifferent_access
41
+ # The config can specify a mapping of:
42
+ # {"service-1" => MyHandler }
43
+ # or
44
+ # {"service-2" => "MyOtherHandler"}
45
+ # We need to support both, because `MyHandler` is not loaded yet when Rails initializers run.
46
+ # Zeitwerk takes over after the initializers. So we can't really use a module in the init cycle just yet.
47
+ # We can, however, use the module name - and resolve it lazily, later.
48
+ handler_class_or_class_name = active_handlers.fetch(service_id_str)
49
+ handler_class = handler_class_or_class_name.respond_to?(:constantize) ? handler_class_or_class_name.constantize : handler_class_or_class_name
50
+ handler_class.new
51
+ rescue KeyError
52
+ raise UnknownHandler
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "controllers/receive_webhooks_controller"
4
+ require_relative "jobs/processing_job"
5
+ require_relative "models/received_webhook"
6
+ require_relative "base_handler"
7
+
8
+ module Webhukhs
9
+ class Engine < ::Rails::Engine
10
+ isolate_namespace Webhukhs
11
+
12
+ autoload :ReceiveWebhooksController, "webhukhs/controllers/receive_webhooks_controller"
13
+ autoload :ProcessingJob, "webhukhs/jobs/processing_job"
14
+ autoload :BaseHandler, "webhukhs/base_handler"
15
+
16
+ generators do
17
+ require_relative "install_generator"
18
+ end
19
+
20
+ routes do
21
+ post "/:service_id" => "received_webhooks#create"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Webhukhs
7
+ #
8
+ # Rails generator used for setting up Webhukhs in a Rails application.
9
+ # Run it with +bin/rails g webhukhs:install+ in your console.
10
+ #
11
+ class InstallGenerator < Rails::Generators::Base
12
+ include ActiveRecord::Generators::Migration
13
+
14
+ source_root File.expand_path("../templates", __FILE__)
15
+
16
+ def create_migration_file
17
+ migration_template "create_webhukhs_tables.rb.erb", File.join(db_migrate_path, "create_webhukhs_tables.rb")
18
+ migration_template "add_headers_to_webhukhs_webhooks.rb.erb", File.join(db_migrate_path, "add_headers_to_webhukhs_webhooks.rb")
19
+ end
20
+
21
+ def copy_files
22
+ template "webhukhs.rb", File.join("config", "initializers", "webhukhs.rb")
23
+ end
24
+
25
+ private
26
+
27
+ def migration_version
28
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job/railtie"
4
+
5
+ module Webhukhs
6
+ class ProcessingJob < ActiveJob::Base
7
+ def perform(webhook)
8
+ Rails.error.set_context(webhukhs_handler_module_name: webhook.handler_module_name, **Webhukhs.configuration.error_context)
9
+
10
+ webhook_details_for_logs = "Webhukhs::ReceivedWebhook#%s (handler: %s)" % [webhook.id, webhook.handler]
11
+ webhook.with_lock do
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
16
+ webhook.processing!
17
+ end
18
+
19
+ if webhook.handler.valid?(webhook.request)
20
+ logger.info { "#{webhook_details_for_logs} starting to process" }
21
+ webhook.handler.process(webhook)
22
+ webhook.processed! if webhook.processing?
23
+ logger.info { "#{webhook_details_for_logs} processed" }
24
+ else
25
+ logger.info { "#{webhook_details_for_logs} did not pass validation by the handler. Marking it `failed_validation`." }
26
+ webhook.failed_validation!
27
+ end
28
+ rescue => e
29
+ webhook.error!
30
+ raise e
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "state_machine_enum"
4
+
5
+ module Webhukhs
6
+ class ReceivedWebhook < ActiveRecord::Base
7
+ self.implicit_order_column = "created_at"
8
+ self.table_name = "received_webhooks"
9
+
10
+ include StateMachineEnum
11
+
12
+ state_machine_enum :status do |s|
13
+ s.permit_transition(:received, :processing)
14
+ s.permit_transition(:processing, :failed_validation)
15
+ s.permit_transition(:processing, :skipped)
16
+ s.permit_transition(:processing, :processed)
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
23
+ end
24
+
25
+ # Store the pertinent data from an ActionDispatch::Request into the webhook.
26
+ # @param [ActionDispatch::Request]
27
+ def request=(action_dispatch_request)
28
+ # Filter out all Rack-specific headers such as "rack.input" and the like. We are
29
+ # only interested in the actual HTTP headers presented by the webserver. Mostly...
30
+ headers = action_dispatch_request.env.filter_map do |(header_name, header_value)|
31
+ if header_name.is_a?(String) && header_name.upcase == header_name && header_value.is_a?(String)
32
+ [header_name, header_value]
33
+ end
34
+ end.to_h
35
+
36
+ # ...except the path parameters - they do not get parsed from the headers, but instead get set by Journey - the Rails
37
+ # router - when the ActionDispatch::Request object gets instantiated. They need to be preserved separately in case the Webhukhs
38
+ # controller gets mounted under a parametrized path - and the path component actually is a parameter that the webhook
39
+ # handler either needs for validation or for processing
40
+ headers["action_dispatch.request.path_parameters"] = action_dispatch_request.env.fetch("action_dispatch.request.path_parameters")
41
+
42
+ # ...and the raw request body - because we already save it separately
43
+ headers.delete("RAW_POST_DATA")
44
+
45
+ # Verify the request body is not too large
46
+ request_body_io = action_dispatch_request.env.fetch("rack.input")
47
+ if request_body_io.size > Webhukhs.configuration.request_body_size_limit
48
+ raise "Cannot accept the webhook as the request body is larger than #{limit} bytes"
49
+ end
50
+
51
+ write_attribute("body", request_body_io.read.force_encoding(Encoding::BINARY))
52
+ write_attribute("request_headers", headers)
53
+ ensure
54
+ request_body_io.rewind
55
+ end
56
+
57
+ # A Webhukhs handler is, in a way, a tiny Rails controller which runs in a background job. To allow this,
58
+ # we need to provide access not only to the webhook payload (the HTTP request body, usually), but also
59
+ # to the rest of the HTTP request - such as headers and route params. For example, imagine you use
60
+ # a system where your multiple tenants (users) may receive webhooks from the same sender. However, you
61
+ # need to dispatch those webhooks to those particular tenants of your application. Instead of mounting
62
+ # Webhukhs as an engine under a common route, you mount it like so:
63
+ #
64
+ # post "/incoming-webhooks/:user_id/:service_id" => "webhukhs/receive_webhooks#create"
65
+ #
66
+ # This way, the tenant ID (the `user_id`) parameter is not going to be provided to you inside the webhook
67
+ # payload, as the sender is not sending it to you at all. However, you do have that parameter in your
68
+ # route. When processing the webhook, it is important for you to know which tenant has received the
69
+ # webhook - so that you can manipulate their data, and not the data belonging to another tenant. With
70
+ # validation, it is important too - in such a multitenant setup every user is likely to have their own,
71
+ # specific signing secret that they have set up. To find that secret and compare the signature, you
72
+ # need access to that `user_id` parameter.
73
+ #
74
+ # To allow access to these, Webhukhs allows the ActionDispatch::Request object to be persisted. The
75
+ # persistence is not 1:1 - the Request is a fairly complex object, with lots of things injected into it
76
+ # by the Rails stack. Not all of those injected properties (Rack headers) are marshalable, some of them
77
+ # depend on the Rails application configuration, etc. However, we do retain the most important things
78
+ # for webhooks to be correctly handled.
79
+ #
80
+ # * The HTTP request body
81
+ # * The headers set by the webserver and the downstream proxies
82
+ # * The request body and query string params, depending on the MIME type
83
+ # * The route params. These are set by Journey (the Rails router) and cannot be reconstructed from a "bare" request
84
+ #
85
+ # While this reconstruction is best-effort it might not be lossless. For example, there might be no access
86
+ # to Rack hijack, streaming APIs, the cookie jar or other more high-level Rails request access features.
87
+ # You will, however, have the basics in place - such as the params, the request body, the path params
88
+ # (as were decoded by your routes) etc. But it should be sufficient to do the basic tasks to process a webhook.
89
+ #
90
+ # @return [ActionDispatch::Request]
91
+ def request
92
+ ActionDispatch::Request.new(request_headers.merge!("rack.input" => StringIO.new(body.to_s.b)))
93
+ end
94
+
95
+ def handler
96
+ handler_module_name.constantize.new
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,5 @@
1
+ class AddHeadersToWebhukhsWebhooks < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ add_column :received_webhooks, :request_headers, :json, null: true
4
+ end
5
+ end
@@ -0,0 +1,23 @@
1
+ class CreateWebhukhsTables < ActiveRecord::Migration<%= migration_version %>
2
+ <% id_type = Rails.application.config.generators.options[:active_record][:primary_key_type] rescue nil %>
3
+ def change
4
+ create_table :received_webhooks <%= ", id: :#{id_type}" if id_type %> do |t|
5
+ t.string :handler_event_id, null: false
6
+ t.string :handler_module_name, null: false
7
+ t.string :status, default: "received", null: false
8
+
9
+ # We don't assume that we can always parse the received body as JSON. Body could be invalid or partly missing,
10
+ # we can argue how we can handle that for different integrations, but we still should be able to save this data
11
+ # if it's required. Hence, we don't use :jsonb, but :binary type column here.
12
+ t.binary :body, null: false
13
+
14
+ t.timestamps
15
+ end
16
+
17
+ # For deduplication at ingress. UNIQUE indices in Postgres are case-sensitive
18
+ # which is what we want, as these are externally-generated IDs
19
+ add_index :received_webhooks, [:handler_module_name, :handler_event_id], unique: true, name: "webhook_dedup_idx"
20
+ # For backfill processing (to know how many skipped etc. payloads we have)
21
+ add_index :received_webhooks, [:status]
22
+ end
23
+ end
@@ -0,0 +1,40 @@
1
+ Webhukhs.configure do |config|
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 `Webhukhs::BaseHandler` in terms of interface.
4
+ # Use module names (strings) here to allow the handler modules to be lazy-loaded by Rails.
5
+ #
6
+ # Example:
7
+ # {:test => "TestHandler", :inactive => "InactiveHandler"}
8
+ config.active_handlers = {}
9
+
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 Webhukhs::ProcessingJob because the background
12
+ # job also manages the webhook state.
13
+ #
14
+ # Example:
15
+ #
16
+ # class WebhookProcessingJob < Webhukhs::ProcessingJob
17
+ # def perform(webhook)
18
+ # TokenLock.with(name: "webhook-processing-#{webhook.id}") do
19
+ # super(webhook)
20
+ # end
21
+ # end
22
+ #
23
+ # In the config a string with your job' class name can be used so that the job can be lazy-loaded by Rails:
24
+ #
25
+ # config.processing_job_class = "WebhookProcessingJob"
26
+
27
+ # We're using a common interface for error reporting provided by Rails, e.g Rails.error.report. In some cases
28
+ # you want to enhance those errors with additional context. As example to provide a namespace:
29
+ #
30
+ # { appsignal: { namespace: "webhooks" } }
31
+ #
32
+ # config.error_context = { appsignal: { namespace: "webhooks" } }
33
+
34
+ # Incoming webhooks will be written into your DB without any prior validation. By default, Webhukhs 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
40
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webhukhs
4
+ VERSION = "0.5.0"
5
+ end
data/lib/webhukhs.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "webhukhs/version"
4
+ require_relative "webhukhs/engine"
5
+ require_relative "webhukhs/jobs/processing_job"
6
+ require "active_support/core_ext/class/attribute"
7
+
8
+ module Webhukhs
9
+ def self.configuration
10
+ @configuration ||= Configuration.new
11
+ end
12
+
13
+ def self.configure
14
+ yield configuration
15
+ end
16
+ end
17
+
18
+ class Webhukhs::Configuration
19
+ class_attribute :processing_job_class, default: Webhukhs::ProcessingJob
20
+ class_attribute :active_handlers, default: {}
21
+ class_attribute :error_context, default: {}
22
+ class_attribute :request_body_size_limit, default: 512.kilobytes
23
+ 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 < Webhukhs::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 < Webhukhs::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 < Webhukhs::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,66 @@
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/webhukhs"
27
+ require_relative "test-webhook-handlers/webhook_test_handler"
28
+ require_relative "test-webhook-handlers/inactive_handler"
29
+ require_relative "test-webhook-handlers/invalid_handler"
30
+ require_relative "test-webhook-handlers/private_handler"
31
+ require_relative "test-webhook-handlers/failing_with_exposed_errors"
32
+ require_relative "test-webhook-handlers/failing_with_concealed_errors"
33
+ require_relative "test-webhook-handlers/extract_id_handler"
34
+
35
+ Webhukhs.configure do |config|
36
+ config.active_handlers = {
37
+ test: "WebhookTestHandler",
38
+ inactive: "InactiveHandler",
39
+ invalid: "InvalidHandler",
40
+ private: "PrivateHandler",
41
+ "failing-with-exposed-errors": "FailingWithExposedErrors",
42
+ "failing-with-concealed-errors": "FailingWithConcealedErrors",
43
+ extract_id: "ExtractIdHandler"
44
+ }
45
+ end
46
+
47
+ class WebhukhsTestApp < Rails::Application
48
+ config.logger = Logger.new(nil)
49
+ config.autoload_paths << File.dirname(__FILE__) + "/test-webhook-handlers"
50
+ config.root = __dir__
51
+ config.eager_load = false
52
+ config.consider_all_requests_local = true
53
+ config.secret_key_base = "i_am_a_secret"
54
+ config.active_support.cache_format_version = 7.1
55
+ config.active_job.queue_adapter = :test
56
+ config.hosts << ->(host) { true } # Permit all hosts
57
+
58
+ routes.append do
59
+ mount Webhukhs::Engine, at: "/webhukhs"
60
+ post "/per-user-webhukhs/:user_id/:service_id" => "webhukhs/receive_webhooks#create"
61
+ end
62
+ end
63
+
64
+ WebhukhsTestApp.initialize!
65
+
66
+ # run WebhukhsTestApp
@@ -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