munster 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.editorconfig +14 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +18 -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 +208 -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/test_handler.rb +28 -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/munster.rb +7 -0
- data/example/config/initializers/permissions_policy.rb +13 -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_munster_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/lib/munster/base_handler.rb +67 -0
- data/lib/munster/controllers/receive_webhooks_controller.rb +52 -0
- data/lib/munster/engine.rb +22 -0
- data/lib/munster/install_generator.rb +30 -0
- data/lib/munster/jobs/processing_job.rb +14 -0
- data/lib/munster/models/received_webhook.rb +23 -0
- data/lib/munster/state_machine_enum.rb +125 -0
- data/lib/munster/templates/create_munster_tables.rb.erb +23 -0
- data/lib/munster/templates/munster.rb +4 -0
- data/lib/munster/version.rb +5 -0
- data/lib/munster.rb +23 -0
- data/lib/tasks/munster_tasks.rake +4 -0
- data/sig/munster.rbs +4 -0
- metadata +197 -0
@@ -0,0 +1,67 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>The change you wanted was rejected (422)</title>
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
6
|
+
<style>
|
7
|
+
.rails-default-error-page {
|
8
|
+
background-color: #EFEFEF;
|
9
|
+
color: #2E2F30;
|
10
|
+
text-align: center;
|
11
|
+
font-family: arial, sans-serif;
|
12
|
+
margin: 0;
|
13
|
+
}
|
14
|
+
|
15
|
+
.rails-default-error-page div.dialog {
|
16
|
+
width: 95%;
|
17
|
+
max-width: 33em;
|
18
|
+
margin: 4em auto 0;
|
19
|
+
}
|
20
|
+
|
21
|
+
.rails-default-error-page div.dialog > div {
|
22
|
+
border: 1px solid #CCC;
|
23
|
+
border-right-color: #999;
|
24
|
+
border-left-color: #999;
|
25
|
+
border-bottom-color: #BBB;
|
26
|
+
border-top: #B00100 solid 4px;
|
27
|
+
border-top-left-radius: 9px;
|
28
|
+
border-top-right-radius: 9px;
|
29
|
+
background-color: white;
|
30
|
+
padding: 7px 12% 0;
|
31
|
+
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
32
|
+
}
|
33
|
+
|
34
|
+
.rails-default-error-page h1 {
|
35
|
+
font-size: 100%;
|
36
|
+
color: #730E15;
|
37
|
+
line-height: 1.5em;
|
38
|
+
}
|
39
|
+
|
40
|
+
.rails-default-error-page div.dialog > p {
|
41
|
+
margin: 0 0 1em;
|
42
|
+
padding: 1em;
|
43
|
+
background-color: #F7F7F7;
|
44
|
+
border: 1px solid #CCC;
|
45
|
+
border-right-color: #999;
|
46
|
+
border-left-color: #999;
|
47
|
+
border-bottom-color: #999;
|
48
|
+
border-bottom-left-radius: 4px;
|
49
|
+
border-bottom-right-radius: 4px;
|
50
|
+
border-top-color: #DADADA;
|
51
|
+
color: #666;
|
52
|
+
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
53
|
+
}
|
54
|
+
</style>
|
55
|
+
</head>
|
56
|
+
|
57
|
+
<body class="rails-default-error-page">
|
58
|
+
<!-- This file lives in public/422.html -->
|
59
|
+
<div class="dialog">
|
60
|
+
<div>
|
61
|
+
<h1>The change you wanted was rejected.</h1>
|
62
|
+
<p>Maybe you tried to change something you didn't have access to.</p>
|
63
|
+
</div>
|
64
|
+
<p>If you are the application owner check the logs for more information.</p>
|
65
|
+
</div>
|
66
|
+
</body>
|
67
|
+
</html>
|
@@ -0,0 +1,66 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>We're sorry, but something went wrong (500)</title>
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
6
|
+
<style>
|
7
|
+
.rails-default-error-page {
|
8
|
+
background-color: #EFEFEF;
|
9
|
+
color: #2E2F30;
|
10
|
+
text-align: center;
|
11
|
+
font-family: arial, sans-serif;
|
12
|
+
margin: 0;
|
13
|
+
}
|
14
|
+
|
15
|
+
.rails-default-error-page div.dialog {
|
16
|
+
width: 95%;
|
17
|
+
max-width: 33em;
|
18
|
+
margin: 4em auto 0;
|
19
|
+
}
|
20
|
+
|
21
|
+
.rails-default-error-page div.dialog > div {
|
22
|
+
border: 1px solid #CCC;
|
23
|
+
border-right-color: #999;
|
24
|
+
border-left-color: #999;
|
25
|
+
border-bottom-color: #BBB;
|
26
|
+
border-top: #B00100 solid 4px;
|
27
|
+
border-top-left-radius: 9px;
|
28
|
+
border-top-right-radius: 9px;
|
29
|
+
background-color: white;
|
30
|
+
padding: 7px 12% 0;
|
31
|
+
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
32
|
+
}
|
33
|
+
|
34
|
+
.rails-default-error-page h1 {
|
35
|
+
font-size: 100%;
|
36
|
+
color: #730E15;
|
37
|
+
line-height: 1.5em;
|
38
|
+
}
|
39
|
+
|
40
|
+
.rails-default-error-page div.dialog > p {
|
41
|
+
margin: 0 0 1em;
|
42
|
+
padding: 1em;
|
43
|
+
background-color: #F7F7F7;
|
44
|
+
border: 1px solid #CCC;
|
45
|
+
border-right-color: #999;
|
46
|
+
border-left-color: #999;
|
47
|
+
border-bottom-color: #999;
|
48
|
+
border-bottom-left-radius: 4px;
|
49
|
+
border-bottom-right-radius: 4px;
|
50
|
+
border-top-color: #DADADA;
|
51
|
+
color: #666;
|
52
|
+
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
53
|
+
}
|
54
|
+
</style>
|
55
|
+
</head>
|
56
|
+
|
57
|
+
<body class="rails-default-error-page">
|
58
|
+
<!-- This file lives in public/500.html -->
|
59
|
+
<div class="dialog">
|
60
|
+
<div>
|
61
|
+
<h1>We're sorry, but something went wrong.</h1>
|
62
|
+
</div>
|
63
|
+
<p>If you are the application owner check the logs for more information.</p>
|
64
|
+
</div>
|
65
|
+
</body>
|
66
|
+
</html>
|
File without changes
|
File without changes
|
File without changes
|
@@ -0,0 +1 @@
|
|
1
|
+
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
ENV["RAILS_ENV"] ||= "test"
|
4
|
+
require_relative "../config/environment"
|
5
|
+
require "rails/test_help"
|
6
|
+
|
7
|
+
class ActiveSupport::TestCase
|
8
|
+
# Run tests in parallel with specified workers
|
9
|
+
parallelize(workers: :number_of_processors)
|
10
|
+
|
11
|
+
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
|
12
|
+
fixtures :all
|
13
|
+
|
14
|
+
# Add more helper methods to be used by all tests here...
|
15
|
+
end
|
data/example/tmp/.keep
ADDED
File without changes
|
File without changes
|
File without changes
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "jobs/processing_job"
|
4
|
+
|
5
|
+
module Munster
|
6
|
+
class BaseHandler
|
7
|
+
class << self
|
8
|
+
# Reimplement this method, it's being used in WebhooksController to store incoming webhook.
|
9
|
+
# Also que for processing in the end.
|
10
|
+
# @return [void]
|
11
|
+
def handle(action_dispatch_request)
|
12
|
+
binary_body_str = action_dispatch_request.body.read.force_encoding(Encoding::BINARY)
|
13
|
+
attrs = {
|
14
|
+
body: binary_body_str,
|
15
|
+
handler_module_name: name,
|
16
|
+
handler_event_id: extract_event_id_from_request(action_dispatch_request)
|
17
|
+
}
|
18
|
+
webhook = Munster::ReceivedWebhook.create!(**attrs)
|
19
|
+
|
20
|
+
Munster.configuration.processing_job_class.perform_later(webhook)
|
21
|
+
rescue ActiveRecord::RecordNotUnique # Deduplicated
|
22
|
+
nil
|
23
|
+
end
|
24
|
+
|
25
|
+
# This method will be used to process webhook by async worker.
|
26
|
+
def process(received_webhook)
|
27
|
+
end
|
28
|
+
|
29
|
+
# This should be defined for each webhook handler and should be unique.
|
30
|
+
# Otherwise controller will never pick up, that this handler exists.
|
31
|
+
#
|
32
|
+
# Please consider that this will be used in url, so don't use underscores or any other symbols that are not used in URL.
|
33
|
+
def service_id
|
34
|
+
:base
|
35
|
+
end
|
36
|
+
|
37
|
+
# This method verifies that request actually comes from provider:
|
38
|
+
# signature validation, HTTP authentication, IP whitelisting and the like
|
39
|
+
def valid?(action_dispatch_request)
|
40
|
+
true
|
41
|
+
end
|
42
|
+
|
43
|
+
# Default implementation just generates UUID, but if the webhook sender sends us
|
44
|
+
# an event ID we use it for deduplication.
|
45
|
+
def extract_event_id_from_request(action_dispatch_request)
|
46
|
+
SecureRandom.uuid
|
47
|
+
end
|
48
|
+
|
49
|
+
# Webhook senders have varying retry behaviors, and often you want to "pretend"
|
50
|
+
# everything is fine even though there is an error so that they keep sending you
|
51
|
+
# data and do not disable your endpoint forcibly. We allow this to be configured
|
52
|
+
# on a per-handler basis - a better webhooks sender will be able to make out
|
53
|
+
# some sense of the errors.
|
54
|
+
def expose_errors_to_sender?
|
55
|
+
false
|
56
|
+
end
|
57
|
+
|
58
|
+
# Tells the controller whether this handler is active or not. This can be used
|
59
|
+
# to deactivate a particular handler via feature flags for example, or use other
|
60
|
+
# logic to determine whether the handler may be used to create new received webhooks
|
61
|
+
# in the system. This is primarily needed for load shedding.
|
62
|
+
def active?
|
63
|
+
true
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Munster
|
4
|
+
class ReceiveWebhooksController < ActionController::API
|
5
|
+
class HandlerRefused < StandardError
|
6
|
+
end
|
7
|
+
|
8
|
+
def create
|
9
|
+
handler = lookup_handler(params[:service_id])
|
10
|
+
return render_error("Webhook handler is inactive", :service_unavailable) unless handler.active?
|
11
|
+
|
12
|
+
raise HandlerRefused unless handler.valid?(request)
|
13
|
+
|
14
|
+
# FIXME: Duplicated webhook will be overwritten here and processing job will be quite for second time.
|
15
|
+
# This will generate a following error in this case:
|
16
|
+
# Error performing Munster::ProcessingJob (Job ID: b40f3f28-81be-4c99-bce8-9ad879ec9754) from Async(default) in 9.95ms: ActiveRecord::RecordInvalid (Validation failed: Status Invalid transition from processing to received):
|
17
|
+
#
|
18
|
+
# This should be handled properly.
|
19
|
+
handler.handle(request)
|
20
|
+
head :ok
|
21
|
+
rescue => e
|
22
|
+
# TODO: add exception handler here
|
23
|
+
# Appsignal.add_exception(e)
|
24
|
+
|
25
|
+
if handler&.expose_errors_to_sender?
|
26
|
+
error_for_sender_from_exception(e)
|
27
|
+
else
|
28
|
+
head :ok
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def error_for_sender_from_exception(e)
|
33
|
+
case e
|
34
|
+
when HandlerRefused
|
35
|
+
render_error("Webhook handler did not validate the request (signature or authentication may be invalid)", :forbidden)
|
36
|
+
when JSON::ParserError, KeyError
|
37
|
+
render_error("Required parameters were not present in the request or the request body was not valid JSON", :bad_request)
|
38
|
+
else
|
39
|
+
render_error("Internal error", :internal_server_error)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def render_error(message_str, status_sym)
|
44
|
+
json = {error: message_str}.to_json
|
45
|
+
render(json:, status: status_sym)
|
46
|
+
end
|
47
|
+
|
48
|
+
def lookup_handler(service_id_str)
|
49
|
+
Munster.configuration.active_handlers.index_by(&:service_id).fetch(service_id_str.to_sym)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../munster"
|
4
|
+
require_relative "controllers/receive_webhooks_controller"
|
5
|
+
require_relative "jobs/processing_job"
|
6
|
+
require_relative "models/received_webhook"
|
7
|
+
require_relative "base_handler"
|
8
|
+
|
9
|
+
module Munster
|
10
|
+
class Engine < ::Rails::Engine
|
11
|
+
isolate_namespace Munster
|
12
|
+
|
13
|
+
autoload :Munster, "munster"
|
14
|
+
autoload :ReceiveWebhooksController, "munster/controllers/receive_webhooks_controller"
|
15
|
+
autoload :ProcessingJob, "munster/jobs/processing_job"
|
16
|
+
autoload :BaseHandler, "munster/base_handler"
|
17
|
+
|
18
|
+
generators do
|
19
|
+
require_relative "install_generator"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators"
|
4
|
+
require "rails/generators/active_record"
|
5
|
+
|
6
|
+
module Munster
|
7
|
+
#
|
8
|
+
# Rails generator used for setting up Munster in a Rails application.
|
9
|
+
# Run it with +bin/rails g munster: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_munster_tables.rb.erb", File.join(db_migrate_path, "create_munster_tables.rb")
|
18
|
+
end
|
19
|
+
|
20
|
+
def copy_files
|
21
|
+
template "munster.rb", File.join("config", "initializers", "munster.rb")
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def migration_version
|
27
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_job" if defined?(Rails)
|
4
|
+
|
5
|
+
module Munster
|
6
|
+
class ProcessingJob < ActiveJob::Base
|
7
|
+
def perform(webhook)
|
8
|
+
# TODO: there should be some sort of locking or concurrency control here, but it's outside of
|
9
|
+
# Munsters scope of responsibility. Developer implementing this should decide how this should be handled.
|
10
|
+
webhook.handler.process(webhook)
|
11
|
+
# TODO: remove process attribute
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../state_machine_enum"
|
4
|
+
|
5
|
+
module Munster
|
6
|
+
class ReceivedWebhook < ActiveRecord::Base
|
7
|
+
self.implicit_order_column = "created_at"
|
8
|
+
self.table_name = "received_webhooks"
|
9
|
+
|
10
|
+
include Munster::StateMachineEnum
|
11
|
+
|
12
|
+
state_machine_enum :status do |s|
|
13
|
+
s.permit_transition(:received, :processing)
|
14
|
+
s.permit_transition(:processing, :skipped)
|
15
|
+
s.permit_transition(:processing, :processed)
|
16
|
+
s.permit_transition(:processing, :error)
|
17
|
+
end
|
18
|
+
|
19
|
+
def handler
|
20
|
+
handler_module_name.constantize
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This concern adds a method called "state_enum" useful for defining an enum using
|
4
|
+
# string values along with valid state transitions. Validations will be added for the
|
5
|
+
# state transitions and a proper enum is going to be defined. For example:
|
6
|
+
#
|
7
|
+
# state_machine_enum :state do |states|
|
8
|
+
# states.permit_transition(:created, :approved_pending_settlement)
|
9
|
+
# states.permit_transition(:approved_pending_settlement, :rejected)
|
10
|
+
# states.permit_transition(:created, :rejected)
|
11
|
+
# states.permit_transition(:approved_pending_settlement, :settled)
|
12
|
+
# end
|
13
|
+
module Munster
|
14
|
+
module StateMachineEnum
|
15
|
+
extend ActiveSupport::Concern
|
16
|
+
|
17
|
+
class StatesCollector
|
18
|
+
attr_reader :states
|
19
|
+
attr_reader :after_commit_hooks
|
20
|
+
attr_reader :common_after_commit_hooks
|
21
|
+
attr_reader :after_attribute_write_hooks
|
22
|
+
attr_reader :common_after_write_hooks
|
23
|
+
|
24
|
+
def initialize
|
25
|
+
@transitions = Set.new
|
26
|
+
@states = Set.new
|
27
|
+
@after_commit_hooks = {}
|
28
|
+
@common_after_commit_hooks = []
|
29
|
+
@after_attribute_write_hooks = {}
|
30
|
+
@common_after_write_hooks = []
|
31
|
+
end
|
32
|
+
|
33
|
+
def permit_transition(from, to)
|
34
|
+
@states << from.to_s << to.to_s
|
35
|
+
@transitions << [from.to_s, to.to_s]
|
36
|
+
end
|
37
|
+
|
38
|
+
def may_transition?(from, to)
|
39
|
+
@transitions.include?([from.to_s, to.to_s])
|
40
|
+
end
|
41
|
+
|
42
|
+
def after_inline_transition_to(target_state, &blk)
|
43
|
+
@after_attribute_write_hooks[target_state.to_s] ||= []
|
44
|
+
@after_attribute_write_hooks[target_state.to_s] << blk.to_proc
|
45
|
+
end
|
46
|
+
|
47
|
+
def after_committed_transition_to(target_state, &blk)
|
48
|
+
@after_commit_hooks[target_state.to_s] ||= []
|
49
|
+
@after_commit_hooks[target_state.to_s] << blk.to_proc
|
50
|
+
end
|
51
|
+
|
52
|
+
def after_any_committed_transition(&blk)
|
53
|
+
@common_after_commit_hooks << blk.to_proc
|
54
|
+
end
|
55
|
+
|
56
|
+
def validate(model, attribute_name)
|
57
|
+
return unless model.persisted?
|
58
|
+
|
59
|
+
was = model.attribute_was(attribute_name)
|
60
|
+
is = model[attribute_name]
|
61
|
+
|
62
|
+
unless was == is || @transitions.include?([was, is])
|
63
|
+
model.errors.add(attribute_name, "Invalid transition from #{was} to #{is}")
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
class InvalidState < StandardError
|
69
|
+
end
|
70
|
+
|
71
|
+
class_methods do
|
72
|
+
def state_machine_enum(attribute_name, **options_for_enum)
|
73
|
+
# Collect the states
|
74
|
+
collector = StatesCollector.new
|
75
|
+
yield(collector).tap do
|
76
|
+
# Define the enum using labels, with string values
|
77
|
+
enum_map = collector.states.map(&:to_sym).zip(collector.states.to_a).to_h
|
78
|
+
enum(attribute_name, enum_map, **options_for_enum)
|
79
|
+
|
80
|
+
# Define validations for transitions
|
81
|
+
validates attribute_name, presence: true
|
82
|
+
validate { |model| collector.validate(model, attribute_name) }
|
83
|
+
|
84
|
+
# Define inline hooks
|
85
|
+
before_save do |model|
|
86
|
+
_value_was, value_has_become = model.changes[attribute_name]
|
87
|
+
next unless value_has_become
|
88
|
+
hook_procs = collector.after_attribute_write_hooks[value_has_become].to_a + collector.common_after_write_hooks.to_a
|
89
|
+
hook_procs.each do |hook_proc|
|
90
|
+
hook_proc.call(model)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# Define after commit hooks
|
95
|
+
after_commit do |model|
|
96
|
+
_value_was, value_has_become = model.previous_changes[attribute_name]
|
97
|
+
next unless value_has_become
|
98
|
+
hook_procs = collector.after_commit_hooks[value_has_become].to_a + collector.common_after_commit_hooks.to_a
|
99
|
+
hook_procs.each do |hook_proc|
|
100
|
+
hook_proc.call(model)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# Define the check methods
|
105
|
+
define_method(:"ensure_#{attribute_name}_one_of!") do |*allowed_states|
|
106
|
+
val = self[attribute_name]
|
107
|
+
return if Set.new(allowed_states.map(&:to_s)).include?(val)
|
108
|
+
raise InvalidState, "#{attribute_name} must be one of #{allowed_states.inspect} but was #{val.inspect}"
|
109
|
+
end
|
110
|
+
|
111
|
+
define_method(:"ensure_#{attribute_name}_may_transition_to!") do |next_state|
|
112
|
+
val = self[attribute_name]
|
113
|
+
raise InvalidState, "#{attribute_name} already is #{val.inspect}" if next_state.to_s == val
|
114
|
+
end
|
115
|
+
|
116
|
+
define_method(:"#{attribute_name}_may_transition_to?") do |next_state|
|
117
|
+
val = self[attribute_name]
|
118
|
+
return false if val == next_state.to_s
|
119
|
+
collector.may_transition?(val, next_state)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
class CreateMunsterTables < 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 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
|
data/lib/munster.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "munster/version"
|
4
|
+
require_relative "munster/engine"
|
5
|
+
require "active_support/configurable"
|
6
|
+
require_relative "munster/jobs/processing_job"
|
7
|
+
|
8
|
+
module Munster
|
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 Munster::Configuration
|
19
|
+
include ActiveSupport::Configurable
|
20
|
+
|
21
|
+
config_accessor(:processing_job_class) { Munster::ProcessingJob }
|
22
|
+
config_accessor(:active_handlers) { [] }
|
23
|
+
end
|
data/sig/munster.rbs
ADDED