munster 0.1.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/.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