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.
- checksums.yaml +7 -0
- data/.editorconfig +14 -0
- data/.rubocop.yml +9 -0
- data/.standard.yml +3 -0
- data/Appraisals +13 -0
- data/CHANGELOG.md +54 -0
- data/LICENSE +21 -0
- data/README.md +71 -0
- data/Rakefile +26 -0
- data/config/routes.rb +3 -0
- data/example/.gitattributes +7 -0
- data/example/.gitignore +29 -0
- data/example/.ruby-version +1 -0
- data/example/Gemfile +32 -0
- data/example/Gemfile.lock +228 -0
- data/example/README.md +24 -0
- data/example/Rakefile +8 -0
- data/example/app/assets/images/.keep +0 -0
- data/example/app/assets/stylesheets/application.css +1 -0
- data/example/app/controllers/application_controller.rb +4 -0
- data/example/app/controllers/concerns/.keep +0 -0
- data/example/app/helpers/application_helper.rb +4 -0
- data/example/app/models/application_record.rb +5 -0
- data/example/app/models/concerns/.keep +0 -0
- data/example/app/views/layouts/application.html.erb +15 -0
- data/example/app/webhooks/webhook_test_handler.rb +13 -0
- data/example/bin/bundle +109 -0
- data/example/bin/rails +4 -0
- data/example/bin/rake +4 -0
- data/example/bin/setup +33 -0
- data/example/config/application.rb +39 -0
- data/example/config/boot.rb +5 -0
- data/example/config/credentials.yml.enc +1 -0
- data/example/config/database.yml +25 -0
- data/example/config/environment.rb +7 -0
- data/example/config/environments/development.rb +61 -0
- data/example/config/environments/production.rb +71 -0
- data/example/config/environments/test.rb +52 -0
- data/example/config/initializers/assets.rb +14 -0
- data/example/config/initializers/content_security_policy.rb +27 -0
- data/example/config/initializers/filter_parameter_logging.rb +10 -0
- data/example/config/initializers/generators.rb +7 -0
- data/example/config/initializers/inflections.rb +18 -0
- data/example/config/initializers/permissions_policy.rb +13 -0
- data/example/config/initializers/webhukhs.rb +9 -0
- data/example/config/locales/en.yml +33 -0
- data/example/config/puma.rb +45 -0
- data/example/config/routes.rb +5 -0
- data/example/config.ru +8 -0
- data/example/db/migrate/20240523125859_create_webhukhs_tables.rb +22 -0
- data/example/db/schema.rb +24 -0
- data/example/db/seeds.rb +9 -0
- data/example/lib/assets/.keep +0 -0
- data/example/lib/tasks/.keep +0 -0
- data/example/log/.keep +0 -0
- data/example/public/404.html +67 -0
- data/example/public/422.html +67 -0
- data/example/public/500.html +66 -0
- data/example/public/apple-touch-icon-precomposed.png +0 -0
- data/example/public/apple-touch-icon.png +0 -0
- data/example/public/favicon.ico +0 -0
- data/example/public/robots.txt +1 -0
- data/example/test/controllers/.keep +0 -0
- data/example/test/fixtures/files/.keep +0 -0
- data/example/test/helpers/.keep +0 -0
- data/example/test/integration/.keep +0 -0
- data/example/test/models/.keep +0 -0
- data/example/test/test_helper.rb +15 -0
- data/example/tmp/.keep +0 -0
- data/example/tmp/pids/.keep +0 -0
- data/example/vendor/.keep +0 -0
- data/gemfiles/rails_7.1.gemfile +10 -0
- data/gemfiles/rails_7.1.gemfile.lock +412 -0
- data/gemfiles/rails_8.0.gemfile +10 -0
- data/gemfiles/rails_8.0.gemfile.lock +405 -0
- data/gemfiles/rails_8.1.gemfile +10 -0
- data/gemfiles/rails_8.1.gemfile.lock +405 -0
- data/handler-examples/customer_io_handler.rb +36 -0
- data/handler-examples/revolut_business_v1_handler.rb +29 -0
- data/handler-examples/revolut_business_v2_handler.rb +42 -0
- data/handler-examples/starling_payments_handler.rb +25 -0
- data/lib/tasks/webhukhs_tasks.rake +4 -0
- data/lib/webhukhs/base_handler.rb +100 -0
- data/lib/webhukhs/controllers/receive_webhooks_controller.rb +55 -0
- data/lib/webhukhs/engine.rb +24 -0
- data/lib/webhukhs/install_generator.rb +31 -0
- data/lib/webhukhs/jobs/processing_job.rb +33 -0
- data/lib/webhukhs/models/received_webhook.rb +99 -0
- data/lib/webhukhs/templates/add_headers_to_webhukhs_webhooks.rb.erb +5 -0
- data/lib/webhukhs/templates/create_webhukhs_tables.rb.erb +23 -0
- data/lib/webhukhs/templates/webhukhs.rb +40 -0
- data/lib/webhukhs/version.rb +5 -0
- data/lib/webhukhs.rb +23 -0
- data/test/test-webhook-handlers/.DS_Store +0 -0
- data/test/test-webhook-handlers/extract_id_handler.rb +5 -0
- data/test/test-webhook-handlers/failing_with_concealed_errors.rb +7 -0
- data/test/test-webhook-handlers/failing_with_exposed_errors.rb +7 -0
- data/test/test-webhook-handlers/inactive_handler.rb +5 -0
- data/test/test-webhook-handlers/invalid_handler.rb +5 -0
- data/test/test-webhook-handlers/private_handler.rb +3 -0
- data/test/test-webhook-handlers/webhook_test_handler.rb +13 -0
- data/test/test_app.rb +66 -0
- data/test/test_helper.rb +50 -0
- data/test/webhukhs_test.rb +203 -0
- 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,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
|
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
|
|
Binary file
|
|
@@ -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
|
data/test/test_helper.rb
ADDED
|
@@ -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
|