-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
11 changed files
with
228 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,6 +2,7 @@ PATH | |
remote: .. | ||
specs: | ||
munster (0.1.0) | ||
rails (~> 7.0) | ||
|
||
GEM | ||
remote: https://rubygems.org/ | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
# frozen_string_literal: true | ||
|
||
class Munster::BaseHandler | ||
class << self | ||
# Reimplement this method, it's being used in WebhooksController to store incoming webhook. | ||
# Also que for processing in the end. | ||
# @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: name, | ||
handler_event_id: extract_event_id_from_request(action_dispatch_request) | ||
} | ||
webhook = Munster::ReceivedWebhook.create!(**attrs) | ||
|
||
## TODO: It should be possible to redefine this job throuhg configuration. | ||
Munster::ProcessingJob.perform_later(webhook) | ||
rescue ActiveRecord::RecordNotUnique # Deduplicated | ||
nil | ||
end | ||
|
||
# This method will be used to process webhook by async worker. | ||
def process(received_webhook) | ||
end | ||
|
||
# This should be defined for each webhook handler and should be unique. | ||
# Otherwise controller will never pick up, that this handler exists. | ||
# | ||
# Please consider that this will be used in url, so don't use underscores or any other symbols that are not used in URL. | ||
def service_id | ||
:base | ||
end | ||
|
||
# This method verifies that request actually comes from provider: | ||
# signature validation, HTTP authentication, IP whitelisting and the like | ||
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. | ||
def extract_event_id_from_request(action_dispatch_request) | ||
SecureRandom.uuid | ||
end | ||
|
||
# Webhook senders have varying retry behaviors, and often you want to "pretend" | ||
# everything is fine even though there is an error so that they keep sending you | ||
# 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. | ||
def expose_errors_to_sender? | ||
false | ||
end | ||
|
||
# Tells the controller whether this handler is active or not. This can be used | ||
# 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. | ||
def active? | ||
true | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
module Munster | ||
class ProcessingJob < ApplicationJob | ||
def perform(webhook) | ||
# TODO: there should be some sort of locking or concurrency control here, but it's outside of | ||
# Munsters scope of responsibility. Developer implementing this should decide how this should be handled. | ||
webhook.handler.process(webhook) | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
# frozen_string_literal: true | ||
|
||
module Munster | ||
class ReceivedWebhook < ApplicationRecord | ||
self.implicit_order_column = "created_at" | ||
# TODO: this should take a configured table name, e.g. it should be possible to use 'received_webhooks' table. | ||
# self.table_name = "munster_received_webhooks" | ||
|
||
include Munster::StateMachineEnum | ||
|
||
state_machine_enum :status do |s| | ||
s.permit_transition(:received, :processing) | ||
s.permit_transition(:processing, :skipped) | ||
s.permit_transition(:processing, :processed) | ||
s.permit_transition(:processing, :error) | ||
end | ||
|
||
def handler | ||
handler_module_name.constantize | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
# frozen_string_literal: true | ||
|
||
# This concern adds a method called "state_enum" useful for defining an enum using | ||
# string values along with valid state transitions. Validations will be added for the | ||
# state transitions and a proper enum is going to be defined. For example: | ||
# | ||
# state_machine_enum :state do |states| | ||
# states.permit_transition(:created, :approved_pending_settlement) | ||
# states.permit_transition(:approved_pending_settlement, :rejected) | ||
# states.permit_transition(:created, :rejected) | ||
# states.permit_transition(:approved_pending_settlement, :settled) | ||
# end | ||
module Munster | ||
module StateMachineEnum | ||
extend ActiveSupport::Concern | ||
|
||
class StatesCollector | ||
attr_reader :states | ||
attr_reader :after_commit_hooks | ||
attr_reader :common_after_commit_hooks | ||
attr_reader :after_attribute_write_hooks | ||
attr_reader :common_after_write_hooks | ||
|
||
def initialize | ||
@transitions = Set.new | ||
@states = Set.new | ||
@after_commit_hooks = {} | ||
@common_after_commit_hooks = [] | ||
@after_attribute_write_hooks = {} | ||
@common_after_write_hooks = [] | ||
end | ||
|
||
def permit_transition(from, to) | ||
@states << from.to_s << to.to_s | ||
@transitions << [from.to_s, to.to_s] | ||
end | ||
|
||
def may_transition?(from, to) | ||
@transitions.include?([from.to_s, to.to_s]) | ||
end | ||
|
||
def after_inline_transition_to(target_state, &blk) | ||
@after_attribute_write_hooks[target_state.to_s] ||= [] | ||
@after_attribute_write_hooks[target_state.to_s] << blk.to_proc | ||
end | ||
|
||
def after_committed_transition_to(target_state, &blk) | ||
@after_commit_hooks[target_state.to_s] ||= [] | ||
@after_commit_hooks[target_state.to_s] << blk.to_proc | ||
end | ||
|
||
def after_any_committed_transition(&blk) | ||
@common_after_commit_hooks << blk.to_proc | ||
end | ||
|
||
def validate(model, attribute_name) | ||
return unless model.persisted? | ||
|
||
was = model.attribute_was(attribute_name) | ||
is = model[attribute_name] | ||
|
||
unless was == is || @transitions.include?([was, is]) | ||
model.errors.add(attribute_name, "Invalid transition from #{was} to #{is}") | ||
end | ||
end | ||
end | ||
|
||
class InvalidState < StandardError | ||
end | ||
|
||
class_methods do | ||
def state_machine_enum(attribute_name, **options_for_enum) | ||
# Collect the states | ||
collector = StatesCollector.new | ||
yield(collector).tap do | ||
# Define the enum using labels, with string values | ||
enum_map = collector.states.map(&:to_sym).zip(collector.states.to_a).to_h | ||
enum(attribute_name, enum_map, **options_for_enum) | ||
|
||
# Define validations for transitions | ||
validates attribute_name, presence: true | ||
validate { |model| collector.validate(model, attribute_name) } | ||
|
||
# Define inline hooks | ||
before_save do |model| | ||
_value_was, value_has_become = model.changes[attribute_name] | ||
next unless value_has_become | ||
hook_procs = collector.after_attribute_write_hooks[value_has_become].to_a + collector.common_after_write_hooks.to_a | ||
hook_procs.each do |hook_proc| | ||
hook_proc.call(model) | ||
end | ||
end | ||
|
||
# Define after commit hooks | ||
after_commit do |model| | ||
_value_was, value_has_become = model.previous_changes[attribute_name] | ||
next unless value_has_become | ||
hook_procs = collector.after_commit_hooks[value_has_become].to_a + collector.common_after_commit_hooks.to_a | ||
hook_procs.each do |hook_proc| | ||
hook_proc.call(model) | ||
end | ||
end | ||
|
||
# Define the check methods | ||
define_method("ensure_#{attribute_name}_one_of!") do |*allowed_states| | ||
val = self[attribute_name] | ||
return if Set.new(allowed_states.map(&:to_s)).include?(val) | ||
raise InvalidState, "#{attribute_name} must be one of #{allowed_states.inspect} but was #{val.inspect}" | ||
end | ||
|
||
define_method("ensure_#{attribute_name}_may_transition_to!") do |next_state| | ||
val = self[attribute_name] | ||
raise InvalidState, "#{attribute_name} already is #{val.inspect}" if next_state.to_s == val | ||
end | ||
|
||
define_method("#{attribute_name}_may_transition_to?") do |next_state| | ||
val = self[attribute_name] | ||
return false if val == next_state.to_s | ||
collector.may_transition?(val, next_state) | ||
end | ||
end | ||
end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters