Skip to content

Commit

Permalink
moat code
Browse files Browse the repository at this point in the history
  • Loading branch information
skatkov committed May 14, 2024
1 parent 80e0ce6 commit 6f81dcf
Show file tree
Hide file tree
Showing 11 changed files with 228 additions and 10 deletions.
1 change: 1 addition & 0 deletions example/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ PATH
remote: ..
specs:
munster (0.1.0)
rails (~> 7.0)

GEM
remote: https://rubygems.org/
Expand Down
2 changes: 1 addition & 1 deletion example/config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
# require "active_job/railtie"
require "active_job/railtie"
require "active_record/railtie"
# require "active_storage/engine"
require "action_controller/railtie"
Expand Down
5 changes: 1 addition & 4 deletions example/db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

64 changes: 64 additions & 0 deletions lib/munster/base_handler.rb
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
2 changes: 1 addition & 1 deletion lib/munster/install_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
require "rails/generators"
require "rails/generators/active_record"

module Pecorino
module Munster
#
# Rails generator used for setting up Munster in a Rails application.
# Run it with +bin/rails g munster:install+ in your console.
Expand Down
3 changes: 2 additions & 1 deletion lib/munster/migrations/create_munster_tables.rb.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
class CreateMunsterTables < ActiveRecord::Migration<%= migration_version %>
<% id_type = Rails.application.config.generators.options[:active_record][:primary_key_type] rescue nil %>
def change
create_table :munster_received_webhooks, id: :uuid do |t|
create_table :munster_received_webhooks<%= ", id: #{id_type}" if id_type %> do |t|
t.string :handler_event_id, null: false
t.string :handler_module_name, null: false
t.string :status, default: "received", null: false
Expand Down
9 changes: 9 additions & 0 deletions lib/munster/processing_job.rb
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
2 changes: 1 addition & 1 deletion lib/munster/railtie.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module Pecorino
module Munster
class Railtie < Rails::Railtie
generators do
require_relative "install_generator"
Expand Down
22 changes: 22 additions & 0 deletions lib/munster/received_webhook.rb
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
125 changes: 125 additions & 0 deletions lib/munster/state_machine_enum.rb
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
3 changes: 1 addition & 2 deletions munster.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ Gem::Specification.new do |spec|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ["lib"]

# Uncomment to register a new dependency of your gem
# spec.add_dependency "example-gem", "~> 1.0"
spec.add_dependency "rails", "~> 7.0"

# For more information and examples about making a new gem, check out our
# guide at: https://bundler.io/guides/creating_gem.html
Expand Down

0 comments on commit 6f81dcf

Please sign in to comment.