easyhooks 1.0.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -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