Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make validation async #13

Merged
merged 41 commits into from
Jul 23, 2024
Merged
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
7426558
Fix grammar in migration
julik Jul 5, 2024
020f5ca
Add "validate_async?" on BaseHandler
julik Jul 5, 2024
4492fee
Rework the Rakefile
julik Jul 5, 2024
ad49f46
Add x86 gems
julik Jul 5, 2024
d596d87
Allow validation to be async
julik Jul 5, 2024
746152e
Run Rake in GH CI
julik Jul 5, 2024
e8a95ab
Slepping
julik Jul 5, 2024
bb4df21
Tackle the instance/module ambiguity
julik Jul 5, 2024
686c225
Improve BaseHandler comments a bit
julik Jul 5, 2024
ae1bb05
And some more commentage
julik Jul 5, 2024
5bac93e
Some more motions
julik Jul 5, 2024
58107da
Continue
julik Jul 5, 2024
0b5d285
It looks like class/module methods are better
julik Jul 5, 2024
c96b9f5
Treat path params separately
julik Jul 5, 2024
8f238a1
Remove empty Rails test dirs
julik Jul 5, 2024
4e6df37
More docs and things
julik Jul 6, 2024
f7c5121
Yeap
julik Jul 6, 2024
c694b9e
Patch up the README
julik Jul 6, 2024
254c7ed
No need to require Munster within itself
julik Jul 6, 2024
46ee3cf
Apply the migration
julik Jul 6, 2024
33aee2b
Add a route to test path params
julik Jul 6, 2024
4b6d04e
Simplify exception handling
julik Jul 6, 2024
9e10e7d
Allow handlers to be set via strings
julik Jul 6, 2024
9073803
Revert back to instance methods
julik Jul 6, 2024
0d3a8dc
Continue
julik Jul 6, 2024
8fff5ce
Remove unused top-level method
julik Jul 8, 2024
d8fc4f9
Continue evil experiments
julik Jul 8, 2024
790f649
Clean up testing
julik Jul 9, 2024
bab1e70
This test does not do much
julik Jul 12, 2024
5415f95
Make all validations async
julik Jul 12, 2024
abef494
Use "json" for headers
julik Jul 12, 2024
642167c
Continue
julik Jul 14, 2024
2f02a95
Continue
julik Jul 17, 2024
5e8a17f
load global_id/railitie
skatkov Jul 22, 2024
03bc2dd
Continue re-enabling tests
julik Jul 22, 2024
aabb01a
And some more
julik Jul 22, 2024
869f8e0
Some changes to error handling
julik Jul 22, 2024
3bad1d3
Split the test handlers for clarity
julik Jul 22, 2024
07ac440
Update changelog
julik Jul 22, 2024
4942fd0
Remove unused test helper code
julik Jul 23, 2024
bcc1d4c
Fixtures are no longer used
julik Jul 23, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,5 @@ jobs:
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- name: Run tests
run: bundle exec rails test
- name: Tests and lint
run: bundle exec rake
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@
/test/dummy/tmp/

.DS_Store

development.sqlite3*
22 changes: 11 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,31 @@ All notable changes to this project will be documented in this file.

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).

## 0.3.1

### Changed
## Unreleased

- Use `valid?` in the background job instead of the controller. Most common configuration issue is an incorrectly specified signing secret, or an incorrectly implemented input validation. When these happen, it is better to allow the webhook to be reprocessed
- Use instance methods in handlers instead of class methods, as they are shorter to define. Assume a handler module supports `.new` - with a module using singleton methods it may return `self` from `new`.
- In the config, allow the handlers specified as strings. Module resolution in Rails happens after the config gets loaded, because the config may alter the Zeitwerk load paths. To allow the config to get loaded and to allow handlers to be autoloaded using Zeitwerk, the handler modules have to be resolved lazily. This also permits the handlers to be reloadable, like any module under Rails' autoloading control.
- Simplify the Rails app used in tests to be small and keep it in a single file
- If a handler is willing to expose errors to the caller, let Rails rescue the error and display an error page or do whatever else is configured for Rails globally.
- Store request headers with the received webhook to allow for async validation

## 0.3.1

- BaseHandler#expose_errors_to_sender? default to true now.

## 0.3.0

### Changed
- state_machine_enum library was moved in it's own library/gem.

### Fixed
- Provide handled: true attribute for Rails.error.report method, because it is required in Rails 7.0.

## 0.2.0

### Changed

- Handler methods are now defined as instance methods for simplicity.
- Define service_id in initializer with active_handlers, instead of handler class.
- Use ruby 3.0 as a base for standard/rubocop, format all code according to it.

### Added

- Introduce Rails common error reporter ( https://guides.rubyonrails.org/error_reporting.html )
- Use Rails common error reporter ( https://guides.rubyonrails.org/error_reporting.html )

## 0.1.0

Expand Down
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ GEM
nio4r (2.7.3)
nokogiri (1.16.5-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.5-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.5-x86_64-linux)
racc (~> 1.4)
parallel (1.24.0)
Expand Down Expand Up @@ -208,6 +210,7 @@ GEM
activesupport (>= 5.2)
sprockets (>= 3.0.0)
sqlite3 (1.7.3-arm64-darwin)
sqlite3 (1.7.3-x86_64-darwin)
sqlite3 (1.7.3-x86_64-linux)
standard (1.36.0)
language_server-protocol (~> 3.17.0.2)
Expand Down Expand Up @@ -238,6 +241,7 @@ GEM

PLATFORMS
arm64-darwin
x86_64-darwin-20
x86_64-linux

DEPENDENCIES
Expand Down
21 changes: 0 additions & 21 deletions LICENSE.txt

This file was deleted.

9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ If bundler is not being used to manage dependencies, install the gem by executin

Generate migrations and initializer file.

`munster:install`
`bin/rails g munster:install`

Mount munster engine in your routes.

`mount Munster::Engine, at: "/webhooks"`
```ruby
mount Munster::Engine, at: "/webhooks"
```

## Requirements

Expand All @@ -40,7 +42,8 @@ This project depends on two dependencies:
This gem uses [Rails common error reporter](https://guides.rubyonrails.org/error_reporting.html) to report any possible error to services like Honeybadger, Appsignal, Sentry and etc. Most of those services already support this common interface, if not - it's not that hard to add this support on your own.

It's possible to provide additional context for every error. e.g.
```

```ruby
Munster.configure do |config|
config.error_context = { appsignal: { namespace: "webhooks" } }
end
Expand Down
22 changes: 15 additions & 7 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
# frozen_string_literal: true

require "bundler/setup"

APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
load "rails/tasks/engine.rake"

load "rails/tasks/statistics.rake"

require "rake/testtask"
require "bundler/gem_tasks"
require "standard/rake"

Expand All @@ -15,4 +10,17 @@ task :format do
`bundle exec magic_frozen_string_literal .`
end

task default: %i[standard]
Rake::TestTask.new(:test) do |t|
t.libs << "test"
t.libs << "lib"

file_name = ARGV[1]

t.test_files = if file_name
[file_name]
else
FileList["test/**/*_test.rb"]
end
end

task default: [:test, :standard]
14 changes: 0 additions & 14 deletions bin/rails

This file was deleted.

4 changes: 3 additions & 1 deletion example/config/initializers/munster.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@
require_relative "../../app/webhooks/webhook_test_handler"

Munster.configure do |config|
config.active_handlers = [WebhookTestHandler]
config.active_handlers = {
"test-handler" => "WebhookTestHandler"
}
end
6 changes: 3 additions & 3 deletions lib/munster.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def self.configure
class Munster::Configuration
include ActiveSupport::Configurable

config_accessor(:processing_job_class) { Munster::ProcessingJob }
config_accessor(:active_handlers) { [] }
config_accessor(:error_context) { {} }
config_accessor(:processing_job_class, default: Munster::ProcessingJob)
config_accessor(:active_handlers, default: {})
julik marked this conversation as resolved.
Show resolved Hide resolved
config_accessor(:error_context, default: {})
end
64 changes: 43 additions & 21 deletions lib/munster/base_handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,58 @@

module Munster
class BaseHandler
# Gets called from the background job
def self.process(...)
new.process(...)
end

# Reimplement this method, it's being used in WebhooksController to store incoming webhook.
# Also que for processing in the end.
# `handle` accepts the ActionDispatch HTTP request and saves the webhook for later processing. It then
# enqueues an ActiveJob which will perform the processing using `process`.
#
# @param action_dispatch_request[ActionDispatch::Request] the request from the controller
# @return [void]
def handle(action_dispatch_request)
binary_body_str = action_dispatch_request.body.read.force_encoding(Encoding::BINARY)
attrs = {
body: binary_body_str,
handler_module_name: self.class.name,
handler_event_id: extract_event_id_from_request(action_dispatch_request)
}
webhook = Munster::ReceivedWebhook.create!(**attrs)
handler_module_name = is_a?(Munster::BaseHandler) ? self.class.name : to_s
handler_event_id = extract_event_id_from_request(action_dispatch_request)

webhook = Munster::ReceivedWebhook.new(request: action_dispatch_request, handler_event_id: handler_event_id, handler_module_name: handler_module_name)
webhook.save!

Munster.configuration.processing_job_class.perform_later(webhook)
rescue ActiveRecord::RecordNotUnique # Deduplicated
nil
rescue ActiveRecord::RecordNotUnique # Webhook deduplicated
Rails.logger.info { "#{inspect} Webhook #{handler_event_id} is a duplicate delivery and will not be stored." }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: I would suggest to use Rails.error.report("#{inspect} Webhook #{handler_event_id} is a duplicate delivery and will not be stored.", handled: true, severity: :info) instead here. We already rely on it and it feels confusing that we use .logger for one case and .error for another.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aight

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Rails.logger.info { "#{inspect} Webhook #{handler_event_id} is a duplicate delivery and will not be stored." }
Rails.error.report("#{inspect} Webhook #{handler_event_id} is a duplicate delivery and will not be stored.", handled: true, severity: :info)

end

# This method will be used to process webhook by async worker.
# This is the heart of your webhook processing. Override this method and define your processing inside of it.
# The `received_webhook` will provide access to the `ReceivedWebhook` model, which contains the received
# body of the webhook request, but also the full (as-full-as-possible) clone of the original ActionDispatch::Request
# that you can use.
#
# @param received_webhook[Munster::ReceivedWebhook]
# @return [void]
def process(received_webhook)
end

# This method verifies that request actually comes from provider:
# signature validation, HTTP authentication, IP whitelisting and the like
# This method verifies that request is not malformed and actually comes from the webhook sender:
# signature validation, HTTP authentication, IP whitelisting and the like. There is a difference depending
# on whether you validate sync (in the receiving controller) or async (in the processing job):
# Validation is async - it takes place in the background job that gets enqueued to process the webhook.
# The `action_dispatch_request` will be reconstructed from the `ReceivedWebhook` data. Background validation
# is used because the most common misconfiguration that may occur is usually forgetting or misidentifying the
# signature for signed webhooks. If such a misconfiguration has taken place, the background validation
# (instead of rejecting the webhook at input) permits you to still process the webhook once the secrets
# have been configured correctly.
#
# If this method returns `false`, the webhook will be marked as `failed_validation` in the database. If this
# method returns `true`, the `process` method of the handler is going to be called.
#
# @see Munster::ReceivedWebhook#request
# @param action_dispatch_request[ActionDispatch::Request] the reconstructed request from the controller
# @return [Boolean]
def valid?(action_dispatch_request)
true
end

# Default implementation just generates UUID, but if the webhook sender sends us
# an event ID we use it for deduplication.
# Default implementation just generates a random UUID, but if the webhook sender sends us
# an event ID we use it for deduplication. A duplicate webhook is not going to be
# stored in the database if it is already present there.
#
# @return [String]
def extract_event_id_from_request(action_dispatch_request)
SecureRandom.uuid
end
Expand All @@ -47,6 +65,8 @@ def extract_event_id_from_request(action_dispatch_request)
# data and do not disable your endpoint forcibly. We allow this to be configured
# on a per-handler basis - a better webhooks sender will be able to make out
# some sense of the errors.
#
# @return [Boolean]
def expose_errors_to_sender?
true
end
Expand All @@ -55,6 +75,8 @@ def expose_errors_to_sender?
# to deactivate a particular handler via feature flags for example, or use other
# logic to determine whether the handler may be used to create new received webhooks
# in the system. This is primarily needed for load shedding.
#
# @return [Boolean]
def active?
true
end
Expand Down
65 changes: 32 additions & 33 deletions lib/munster/controllers/receive_webhooks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,55 +2,54 @@

module Munster
class ReceiveWebhooksController < ActionController::API
class HandlerRefused < StandardError
class HandlerInactive < StandardError
end

class HandlerInactive < StandardError
class UnknownHandler < StandardError
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: Should we maybe move these error definitions elsewhere? Not sure why they are defined under ReceiveWebhooksController class.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are convenient to place in the controller because they don't need a namespace to raise and to match, and this is the only spot where they get raised. Once we transition to a setup where these errors may get raised from the background job too - we'll move them into the gem namespace.

end

def create
handler = lookup_handler(params[:service_id]).new

Rails.error.set_context(**Munster.configuration.error_context)
handler = lookup_handler(service_id)
raise HandlerInactive unless handler.active?
raise HandlerRefused unless handler.valid?(request)

handler.handle(request)
head :ok
rescue KeyError # handler was not found, so we return generic 404 error.
render_error("Required parameters were not present in the request", :not_found)
render(json: {ok: true, error: nil})
rescue UnknownHandler => e
Rails.error.report(e, handled: true, severity: :error)
render_error_with_status("No handler found for #{service_id.inspect}", status: :not_found)
rescue HandlerInactive => e
Rails.error.report(e, handled: true, severity: :error)
render_error_with_status("Webhook handler #{service_id.inspect} is inactive", status: :service_unavailable)
rescue => e
Rails.error.set_context(**Munster.configuration.error_context)
# Rails 7.1 only requires `error` attribute for .report method, but Rails 7.0 requires `handled:` attribute additionally.
# We're setting `handled:` and `severity:` attributes to maintain compatibility with all versions of > rails 7.
raise e unless handler
raise e if handler.expose_errors_to_sender?
Rails.error.report(e, handled: true, severity: :error)

if handler&.expose_errors_to_sender?
error_for_sender_from_exception(e)
else
head :ok
end
render_error_with_status("Internal error (#{e})")
end

def error_for_sender_from_exception(e)
case e
when HandlerRefused
render_error("Webhook handler did not validate the request (signature or authentication may be invalid)", :forbidden)
when HandlerInactive
render_error("Webhook handler is inactive", :service_unavailable)
when JSON::ParserError
render_error("Request body is not a valid JSON", :bad_request)
else
render_error("Internal error", :internal_server_error)
end
def service_id
params.require(:service_id)
end

def render_error(message_str, status_sym)
json = {error: message_str}.to_json
render(json: json, status: status_sym)
def render_error_with_status(message_str, status: :ok)
json = {ok: false, error: message_str}.to_json
render(json: json, status: status)
end

def lookup_handler(service_id_str)
Munster.configuration.active_handlers.with_indifferent_access.fetch(service_id_str)
active_handlers = Munster.configuration.active_handlers.with_indifferent_access
# The config can specify a mapping of:
# {"service-1" => MyHandler }
# or
# {"service-2" => "MyOtherHandler"}
# We need to support both, because `MyHandler` is not loaded yet when Rails initializers run.
# Zeitwerk takes over after the initializers. So we can't really use a module in the init cycle just yet.
# We can, however, use the module name - and resolve it lazily, later.
handler_class_or_class_name = active_handlers.fetch(service_id_str)
handler_class = handler_class_or_class_name.respond_to?(:constantize) ? handler_class_or_class_name.constantize : handler_class_or_class_name
handler_class.new
rescue KeyError
raise UnknownHandler
end
end
end
Loading