easyhooks 1.0.3

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.
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Easyhooks
6
+ module Validators
7
+ extend ActiveSupport::Concern
8
+
9
+ ALLOWED_ON_VALUES = %i[create update destroy].freeze
10
+ ALLOWED_METHODS = %i[get post put patch delete].freeze
11
+ ALLOWED_TYPES = %i[default stored].freeze
12
+
13
+ included do
14
+ def validate_type(type)
15
+ return :default if type.nil?
16
+ raise TypeError, "Invalid #{self.class} type: #{type}" unless ALLOWED_TYPES.include?(type)
17
+ type
18
+ end
19
+
20
+ def validate_method(method)
21
+ return nil if method.nil? && @type == :stored # this will be loaded by the processor
22
+ return :post unless method.present?
23
+ method = method.downcase if method.is_a?(String)
24
+ raise TypeError, "Invalid method '#{method}' for #{self.class} '#{@name}'. Allowed values are: #{ALLOWED_METHODS}" unless ALLOWED_METHODS.include?(method.to_sym)
25
+ method.to_sym
26
+ end
27
+
28
+ def valid_url?(url)
29
+ URI.parse(url) rescue false
30
+ end
31
+
32
+ def validate_endpoint(endpoint)
33
+ return nil if endpoint.nil? && @type == :stored # this will be loaded by the processor
34
+ raise TypeError, "#{self.class} endpoint can't be nil" unless endpoint.present?
35
+ raise TypeError, "#{self.class} endpoint is not a valid URL: #{endpoint}" unless valid_url?(endpoint)
36
+ endpoint
37
+ end
38
+
39
+ def validate_name(name)
40
+ raise TypeError, "#{self.class} name can't be nil" unless name.present?
41
+ raise TypeError, "Invalid #{self.class} name '#{name}'. Name can only have alphanumeric characters and underscore" unless name =~ /\A[a-zA-Z0-9_]+\z/
42
+ name
43
+ end
44
+
45
+ def validate_on(on)
46
+ return ALLOWED_ON_VALUES if on.nil?
47
+ on = [on] unless on.is_a?(Array)
48
+ on.map do |value|
49
+ raise TypeError, "Invalid attribute 'on' for #{self.class} #{@name}: #{on}. Allowed values are: #{ALLOWED_ON_VALUES}" unless ALLOWED_ON_VALUES.include?(value.to_sym)
50
+ value
51
+ end
52
+ end
53
+
54
+ def validate_only(only)
55
+ return [] if only.nil?
56
+ only = [only] unless only.is_a?(Array)
57
+ only.map(&:to_sym) # convert only array into symbols array
58
+ end
59
+
60
+ def validate_callback(callback, attribute)
61
+ if callback.nil? || callback.is_a?(Symbol) || callback.respond_to?(:call)
62
+ callback
63
+ else
64
+ raise TypeError, "Invalid attribute '#{attribute}' for #{self.class} #{@name}: #{attribute} must be nil, an instance method name symbol or a callable (eg. a proc or lambda)"
65
+ end
66
+ end
67
+
68
+ def validate_auth(auth)
69
+ return nil if auth.nil?
70
+ # validate if auth header is a string and it must be a Bearer token or a Basic auth to include into a HTTP request
71
+ raise TypeError, "Invalid attribute 'auth_header' for #{self.class} #{@name}: #{auth} must be nil or a string" unless auth.is_a?(String)
72
+ raise TypeError, "Invalid attribute 'auth_header' for #{self.class} #{@name}: #{auth} must be a Bearer token or a Basic auth" unless auth =~ /\ABearer\s/ || auth =~ /\ABasic\s/
73
+ auth
74
+ end
75
+
76
+ def validate_headers(headers)
77
+ return {} if headers.nil?
78
+ headers = JSON.parse(headers.gsub(':', '').gsub('=>', ':')) if headers.is_a?(String)
79
+ raise TypeError, "Invalid attribute 'headers' for #{self.class} #{@name}: #{headers} must be nil or a hash" unless headers.is_a?(Hash)
80
+ headers
81
+ end
82
+
83
+ def validate_hook(hook)
84
+ return hook if hook.nil? && @type == :stored
85
+ hook.method = validate_method(hook.method)
86
+ hook.endpoint = validate_endpoint(hook.endpoint)
87
+ hook.auth = validate_auth(hook.auth)
88
+ hook.headers = validate_headers(hook.headers)
89
+ hook
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyhooks
4
+ class Hook
5
+ ATTRIBUTES = [:method, :endpoint, :auth, :headers]
6
+ attr_accessor(*ATTRIBUTES)
7
+ end
8
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../generators/easyhooks/migration/templates/migration'
4
+
5
+ module Easyhooks
6
+ class Migration
7
+
8
+ def self.up
9
+ EasyhooksMigration.migrate(:up)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+ require 'net/http'
3
+
4
+ module Easyhooks
5
+ class PostProcessor < ActiveJob::Base
6
+ queue_as :easyhooks
7
+
8
+ def perform(klass_name, object_id, payload, action_name, action_trigger)
9
+ init_data(klass_name, object_id, payload, action_name, action_trigger)
10
+ begin
11
+ request = create_http_request
12
+ make_request(request)
13
+ trigger_event
14
+ rescue => e
15
+ if @action.on_fail.present? && @object.respond_to?(@action.on_fail_callable)
16
+ @object.send(@action.on_fail_callable, e)
17
+ else
18
+ raise e
19
+ end
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def init_data(klass_name, object_id, payload, action_name, action_trigger)
26
+ @klass_name = klass_name
27
+ @object = find_object(object_id)
28
+ @action_trigger = action_trigger
29
+ @action_name = action_name
30
+ load_action_and_payload(payload)
31
+ end
32
+
33
+ def find_object(object_id)
34
+ @klass = @klass_name.constantize
35
+ @klass.find_by(id: object_id)
36
+ end
37
+
38
+ def json?(value)
39
+ # check if value is a json string
40
+ JSON.parse(value)
41
+ true
42
+ rescue JSON::ParserError
43
+ false
44
+ end
45
+
46
+ def enrich_data(data)
47
+ # if data is a hash or array or a json string
48
+ default = {
49
+ object: @klass_name,
50
+ action: @action.name,
51
+ trigger: {
52
+ name: @action.trigger_name,
53
+ event: @action_trigger.to_s.upcase
54
+ }
55
+ }
56
+ if json?(data)
57
+ default.merge({ data: JSON.parse(data) }).to_json
58
+ else
59
+ default.merge({ data: { id: data }}).to_json
60
+ end
61
+ end
62
+
63
+ def load_action_and_payload(payload)
64
+ @action = @klass.easyhooks_actions[@action_name]
65
+ @action.load!(@klass_name)
66
+ @payload = enrich_data(payload)
67
+ end
68
+
69
+ def create_http_request
70
+ parsed_url = URI.parse(@action.hook.endpoint)
71
+ host = parsed_url.host
72
+ port = parsed_url.port
73
+ raise "Unable to load endpoint: #{@action.hook.endpoint}" unless host.present? && port.present?
74
+
75
+ @http = Net::HTTP.new(host, port)
76
+ @http.use_ssl = true if parsed_url.scheme == 'https'
77
+
78
+ Net::HTTP.const_get(@action.hook.method.to_s.capitalize).new(parsed_url.request_uri)
79
+ end
80
+
81
+ def make_request(request)
82
+ request.body = @payload
83
+ request.add_field('Content-Type', 'application/json')
84
+
85
+ # add headers
86
+ @action.hook.headers.each do |key, value|
87
+ request.add_field(key, value)
88
+ end
89
+
90
+ # adds auth (bearer or basic)
91
+ request.add_field('Authorization', @action.hook.auth) if @action.hook.auth.present?
92
+
93
+ @http.verify_mode = OpenSSL::SSL::VERIFY_NONE if Rails.env.test? # disable SSL verification for test env
94
+ @response = @http.request(request)
95
+ end
96
+
97
+ def trigger_event
98
+ @object.send(@action.event_callable, @response) if @object.respond_to?(@action.event_callable)
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'easyhooks/action'
4
+ require 'easyhooks/trigger'
5
+ require 'easyhooks/hook'
6
+ require 'easyhooks/concerns/helpers'
7
+ require 'easyhooks/concerns/validators'
8
+
9
+ module Easyhooks
10
+ class Specification
11
+ include Easyhooks::Helpers
12
+ include Easyhooks::Validators
13
+
14
+ attr_accessor :name, :type, :global_args, :actions, :triggers, :scoped_action, :scoped_trigger
15
+
16
+ def initialize(name, type, args = {}, &specification)
17
+ @name = name
18
+ @type = type
19
+ @global_args = args.merge({ type: type })
20
+ @triggers = {}
21
+ @actions = {}
22
+ instance_eval(&specification)
23
+ end
24
+
25
+ private
26
+
27
+ def trigger(name, args = {}, &actions)
28
+ # merge args with global args keeping args as the priority
29
+ args = @global_args.merge(args)
30
+
31
+ type = args[:type]
32
+
33
+ hook_definition = find_trigger_hook(name, type, args)
34
+
35
+ # create the trigger
36
+ new_trigger = Easyhooks::Trigger.new(name, hook_definition, args)
37
+ @triggers[name] = new_trigger
38
+ @scoped_trigger = new_trigger
39
+ instance_eval(&actions) if actions
40
+ end
41
+
42
+ def action(name, args = {}, &event)
43
+ args = @scoped_trigger.args.merge(args)
44
+ type = args[:type]
45
+
46
+ hook_definition = find_action_hook(name, type, args)
47
+
48
+ # create the action
49
+ new_action = Easyhooks::Action.new(name, @scoped_trigger.name, hook_definition, args, &event)
50
+ @actions[name] = new_action
51
+ @scoped_action = new_action
52
+ @scoped_trigger.add_action(new_action)
53
+ end
54
+
55
+ def find_trigger_hook(name, type, args)
56
+ hook_definition = Hook.new
57
+ Hook::ATTRIBUTES.each do |field|
58
+ value = hook_lookup(:triggers, name, type, args, field) || hook_lookup(:classes, @name, type, args, field)
59
+ hook_definition.send("#{field}=".to_sym, value)
60
+ end
61
+ hook_definition
62
+ end
63
+
64
+ def find_action_hook(name, type, args)
65
+ hook_definition = Hook.new
66
+ Hook::ATTRIBUTES.each do |field|
67
+ value = hook_lookup(:actions, name, type, args, field) || @scoped_trigger.hook.send(field)
68
+ hook_definition.send("#{field}=".to_sym, value)
69
+ end
70
+ hook_definition
71
+ end
72
+
73
+ def hook_lookup(attr_type, attr_name, type, args, field)
74
+ # stored triggers will load at post processor time
75
+ return nil if type == :stored
76
+
77
+ if args.present?
78
+ value = args[field]&.to_s
79
+ return value if value.present?
80
+ end
81
+
82
+ if config_file_exists? && [:method, :endpoint, :auth].include?(field)
83
+ value = config.dig(Rails.env, attr_type.to_s, attr_name.to_s, field.to_s)&.to_s
84
+ return value if value.present?
85
+ end
86
+
87
+ if config_file_exists? && field == :headers
88
+ value = config.dig(Rails.env, attr_type.to_s, attr_name.to_s, field.to_s)&.to_h
89
+ return value if value.present?
90
+ end
91
+ end
92
+
93
+ def config_file_exists?
94
+ @config_file_exists ||= File.exist?(config_file)
95
+ end
96
+
97
+ def config_file
98
+ args = 'config', 'easyhooks.yml'
99
+ args.unshift('test') if Rails.env.test?
100
+
101
+ @config_file ||= File.join(Rails.root, args)
102
+ end
103
+
104
+ def config
105
+ @config ||= YAML.load_file(config_file)
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './store_values'
4
+
5
+ module Easyhooks
6
+ class Store < ActiveRecord::Base
7
+ self.table_name = 'easyhooks_store'
8
+
9
+ has_many :values, class_name: 'Easyhooks::StoreValues', dependent: :destroy
10
+
11
+ validates_presence_of :name, :method, :endpoint, :context
12
+
13
+ def add_headers(headers)
14
+ headers.each do |key, value|
15
+ values.create(context: 'headers', key: key, value: value)
16
+ end
17
+ end
18
+
19
+ def add_auth(type, auth)
20
+ values.create(context: 'auth', key: type, value: auth)
21
+ end
22
+
23
+ def headers
24
+ values.where(context: 'headers').map { |v| [v.key, v.value] }.to_h
25
+ end
26
+
27
+ def auth
28
+ auth = values.where(context: 'auth').first
29
+ return nil unless auth.present?
30
+
31
+ "#{auth.key} #{auth.value}"
32
+ end
33
+ end
34
+ end
35
+
36
+ # == Schema Information
37
+ #
38
+ # Table name: easyhooks_store
39
+ #
40
+ # id :integer not null, primary key
41
+ # context :string not null
42
+ # name :string not null
43
+ # method :string not null
44
+ # endpoint :string not null
45
+ # created_at :datetime
46
+ # updated_at :datetime
47
+ #
48
+ # Indexes
49
+ #
50
+ # index_easyhooks_store_on_name_and_context (name, context) UNIQUE
51
+ #
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './store'
4
+
5
+ module Easyhooks
6
+ class StoreValues < ActiveRecord::Base
7
+ self.table_name = 'easyhooks_store_values'
8
+
9
+ belongs_to :store, class_name: 'Easyhooks::Store'
10
+
11
+ validates_presence_of :key, :value, :context
12
+ end
13
+ end
14
+
15
+ # == Schema Information
16
+ #
17
+ # Table name: easyhooks_store_values
18
+ #
19
+ # id :integer not null, primary key
20
+ # context :string not null
21
+ # key :string not null
22
+ # value :string not null
23
+ # easyhooks_store_id :integer not null
24
+ # created_at :datetime
25
+ # updated_at :datetime
26
+ #
27
+ # Indexes
28
+ #
29
+ # index_easyhooks_store_values_on_key_and_context (key, context)
30
+ #
31
+ # Foreign Keys
32
+ #
33
+ # fk_rails_... (easyhooks_store_id => easyhooks_store.id)
34
+ #
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'easyhooks/base'
4
+
5
+ module Easyhooks
6
+ class Trigger < Easyhooks::Base
7
+
8
+ attr_accessor :args, :on, :only, :actions
9
+
10
+ def initialize(name, hook, args = {})
11
+ super(name, args[:type], hook, args[:if], args[:payload], args[:on_fail])
12
+ @args = args
13
+ @on = validate_on(args[:on])
14
+ @only = validate_only(args[:only])
15
+ @actions = []
16
+ end
17
+
18
+ def add_action(action)
19
+ @actions.push(action)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyhooks
4
+ VERSION = '1.0.3'
5
+ end
data/lib/easyhooks.rb ADDED
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubygems'
4
+ require 'rails'
5
+ require 'active_support'
6
+ require 'active_record'
7
+ require 'active_job'
8
+ require 'easyhooks/specification'
9
+ require 'easyhooks/post_processor'
10
+
11
+ module Easyhooks
12
+
13
+ module ClassMethods
14
+ attr_reader :easyhooks_spec
15
+
16
+ def easyhooks(type = :default, args = {}, &specification)
17
+ if type.is_a?(Hash)
18
+ args = type
19
+ type = :default
20
+ end
21
+ # get self.class replacing :: from the module::class name with _
22
+ # e.g. MyModule::MyClass becomes MyModule_MyClass
23
+ klass_name = self.name.to_s.gsub('::', '_')
24
+ assign_easyhooks Specification.new(klass_name, type, args, &specification)
25
+ end
26
+
27
+ def easyhooks_actions
28
+ @easyhooks_spec.actions
29
+ end
30
+
31
+ private
32
+
33
+ def assign_easyhooks(specification_object)
34
+ @easyhooks_spec = specification_object
35
+
36
+ @easyhooks_spec.actions.each do |_, action|
37
+ module_eval do
38
+ define_method action.event_callable do |response_data|
39
+ instance_exec(response_data, &action.event) if action.event.present?
40
+ end
41
+
42
+ define_method action.on_fail_callable do |error|
43
+ send(action.on_fail, error)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ module InstanceMethods
51
+ extend ActiveSupport::Concern
52
+
53
+ included do
54
+ after_commit :dispatch_triggers
55
+ end
56
+
57
+ private
58
+
59
+ def triggered_by
60
+ return :create if self.transaction_include_any_action?([:create])
61
+
62
+ return :update if self.transaction_include_any_action?([:update])
63
+
64
+ return :destroy if self.transaction_include_any_action?([:destroy])
65
+
66
+ :none
67
+ end
68
+
69
+ def perform_trigger_actions(trigger)
70
+ trigger.actions.each do |action|
71
+ next unless action.condition_applicable?(self)
72
+ payload = action.request_payload(self).to_json
73
+ PostProcessor.perform_later(self.class.name, self.id, payload, action.name, triggered_by)
74
+ end
75
+ end
76
+
77
+ def execute_trigger(trigger)
78
+ return unless trigger.condition_applicable?(self)
79
+ if trigger.only.empty? || triggered_by == :destroy
80
+ perform_trigger_actions(trigger)
81
+ else
82
+ trigger.only.each do |field|
83
+ perform_trigger_actions(trigger) if self.previous_changes.has_key?(field)
84
+ end
85
+ end
86
+ end
87
+
88
+ def dispatch_triggers
89
+ return unless self.class.easyhooks_spec
90
+
91
+ self.class.easyhooks_spec.triggers.each do |_, trigger|
92
+ execute_trigger(trigger) if self.transaction_include_any_action?(trigger.on)
93
+ end
94
+ end
95
+ end
96
+
97
+ def self.included(klass)
98
+ # check if the klass extends from ActiveRecord::Base, if not raise an error
99
+ unless klass.ancestors.include?(ActiveRecord::Base)
100
+ raise "Easyhooks can only be included in classes that extend from ActiveRecord::Base"
101
+ end
102
+
103
+ klass.send :include, InstanceMethods
104
+
105
+ klass.extend ClassMethods
106
+ end
107
+ end
108
+
109
+ ActiveRecord::Base.send(:include, Easyhooks)
@@ -0,0 +1,28 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/migration'
3
+
4
+ module Easyhooks
5
+ class MigrationGenerator < Rails::Generators::Base
6
+ include Rails::Generators::Migration
7
+
8
+ desc 'Generates migration for easyhooks'
9
+ source_root File.expand_path('../templates', __FILE__)
10
+
11
+ def create_migration_file
12
+ migration_template 'migration.rb','db/migrate/easyhooks_migration.rb'
13
+ end
14
+
15
+ def self.next_migration_number(dirname)
16
+ if timestamped_migrations?
17
+ Time.now.utc.strftime('%Y%m%d%H%M%S')
18
+ else
19
+ '%.3d' % (current_migration_number(dirname) + 1)
20
+ end
21
+ end
22
+
23
+ def self.timestamped_migrations?
24
+ (ActiveRecord::Base.respond_to?(:timestamped_migrations) && ActiveRecord::Base.timestamped_migrations) ||
25
+ (ActiveRecord.respond_to?(:timestamped_migrations) && ActiveRecord.timestamped_migrations)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ class EasyhooksMigration < ActiveRecord::Migration[6.0]
4
+ def self.up
5
+ create_table :easyhooks_store do |t|
6
+ t.string :context, null: false
7
+ t.string :name, null: false
8
+ t.string :method, null: false
9
+ t.string :endpoint, null: false
10
+ t.timestamps null: true
11
+ end
12
+ add_index :easyhooks_store, %i[name context], unique: true
13
+
14
+ create_table :easyhooks_store_values do |t|
15
+ t.string :context, null: false
16
+ t.string :key, null: false
17
+ t.string :value, null: false
18
+ t.integer :store_id, null: false, references: :easyhooks_store
19
+ t.timestamps null: true
20
+ end
21
+ add_index :easyhooks_store_values, %i[context key]
22
+ end
23
+
24
+ def self.down
25
+ drop_table :easyhooks_store_values
26
+ drop_table :easyhooks_store
27
+ end
28
+ end