flu-rails 1.2.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.
@@ -0,0 +1,30 @@
1
+ require_relative "active_record_extender"
2
+ require_relative "action_controller_extender"
3
+
4
+ module Flu
5
+ class CoreExt
6
+ def self.extend_model_classes(event_factory, event_publisher)
7
+ ActiveRecordExtender.extend_models(event_factory, event_publisher)
8
+ end
9
+
10
+ def self.extend_controller_classes(event_factory, event_publisher, logger)
11
+ ActionControllerExtender.extend_controllers(event_factory, event_publisher, logger)
12
+ end
13
+
14
+ def self.flu_tracker_request_id=(value)
15
+ Thread.current[:flu_tracker_request_id] = value
16
+ end
17
+
18
+ def self.flu_tracker_request_id
19
+ Thread.current[:flu_tracker_request_id]
20
+ end
21
+
22
+ def self.flu_tracker_request_entity_metadata=(value)
23
+ Thread.current[:flu_tracker_request_entity_metadata] = value
24
+ end
25
+
26
+ def self.flu_tracker_request_entity_metadata
27
+ Thread.current[:flu_tracker_request_entity_metadata]
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,40 @@
1
+ module Flu
2
+ module Dummy
3
+ class InMemoryEventPublisher < Flu::EventPublisher
4
+
5
+ attr_reader :published_events_by_routing_key, :ordered_published_event_routing_keys
6
+
7
+ def initialize(configuration)
8
+ @logger = configuration.logger
9
+ @configuration = configuration
10
+ @published_events_by_routing_key = {}
11
+ @ordered_published_event_routing_keys = []
12
+ end
13
+
14
+ def publish(event, persistent=true)
15
+ routing_key = event.to_routing_key
16
+ published_events_by_routing_key[routing_key] ||= []
17
+ published_events_by_routing_key[routing_key].push(event)
18
+ @ordered_published_event_routing_keys.push(routing_key)
19
+ end
20
+
21
+ def connect
22
+ end
23
+
24
+ def events_count
25
+ @published_events_by_routing_key.map do | key, value |
26
+ value.size
27
+ end.sum
28
+ end
29
+
30
+ def fetch_events(routing_key)
31
+ @published_events_by_routing_key[routing_key]
32
+ end
33
+
34
+ def clear
35
+ @published_events_by_routing_key = {}
36
+ @ordered_published_event_routing_keys = []
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,106 @@
1
+ require "json"
2
+
3
+ module Flu
4
+ class Event
5
+
6
+ attr_reader :data
7
+
8
+ def initialize(uuid, emitter, kind, name, data)
9
+ raise ArgumentError, "uuid must not be nil" if uuid.nil?
10
+ raise ArgumentError, "emitter must not be nil nor empty" if emitter.nil? || emitter.length == 0
11
+ raise ArgumentError, "kind must not be nil nor empty" if kind.nil? || kind.length == 0
12
+ raise ArgumentError, "name must not be nil nor empty" if name.nil? || name.length == 0
13
+
14
+ @meta = {
15
+ id: uuid,
16
+ name: name,
17
+ emitter: emitter,
18
+ timestamp: Time.now.utc,
19
+ kind: kind,
20
+ status: :new
21
+ }
22
+ @data = data || {}
23
+ end
24
+
25
+ def to_routing_key
26
+ "#{@meta[:status]}.#{@meta[:emitter]}.#{@meta[:kind]}.#{@meta[:name]}"
27
+ end
28
+
29
+ def to_json(options=nil)
30
+ JSON.dump({
31
+ meta: @meta,
32
+ data: map_complex_object(@data)
33
+ })
34
+ end
35
+
36
+ def id
37
+ @meta[:id]
38
+ end
39
+
40
+ def timestamp=(new_timestamp)
41
+ @meta[:timestamp] = new_timestamp
42
+ end
43
+
44
+ def mark_as_replayed
45
+ @meta[:status] = :replayed
46
+ end
47
+
48
+ def emitter
49
+ @meta[:emitter]
50
+ end
51
+
52
+ def timestamp
53
+ @meta[:timestamp]
54
+ end
55
+
56
+ def kind
57
+ @meta[:kind]
58
+ end
59
+
60
+ def name
61
+ @meta[:name]
62
+ end
63
+
64
+ def status
65
+ @meta[:status]
66
+ end
67
+
68
+ private
69
+
70
+ def map_complex_object(object)
71
+ if object.is_a?(Array)
72
+ map_array(object)
73
+ elsif object.is_a?(Hash)
74
+ map_hash(object)
75
+ elsif object.is_a?(ActionDispatch::Http::UploadedFile)
76
+ map_file(object)
77
+ else
78
+ object
79
+ end
80
+ end
81
+
82
+ def map_array(object)
83
+ array = []
84
+ object.each do |value|
85
+ array.push(map_complex_object(value))
86
+ end
87
+ array
88
+ end
89
+
90
+ def map_hash(object)
91
+ hash = {}
92
+ object.each do |key, value|
93
+ hash[key] = map_complex_object(value)
94
+ end
95
+ hash
96
+ end
97
+
98
+ def map_file(object)
99
+ {
100
+ "file_name": object.original_filename,
101
+ "content_type": object.content_type
102
+ }
103
+ end
104
+ end
105
+ end
106
+
@@ -0,0 +1,117 @@
1
+ require "securerandom"
2
+ require_relative "event"
3
+
4
+ module Flu
5
+ class EventFactory
6
+ def initialize(configuration)
7
+ @logger = configuration.logger
8
+ @configuration = configuration
9
+ @emitter = configuration.application_name
10
+ @default_ignored_model_changes = configuration.default_ignored_model_changes.map(&:to_s)
11
+ @default_ignored_request_params = configuration.default_ignored_request_params
12
+ end
13
+
14
+ def build_request_event(data)
15
+ raise ArgumentError, "data must not be nil" if data.nil?
16
+ raise ArgumentError, "data must have an action_name" if !data.has_key?(:action_name) || data[:action_name].empty?
17
+ raise ArgumentError, "data must have an controller_name" if !data.has_key?(:controller_name) || data[:controller_name].empty?
18
+ name = "request to #{data[:action_name]} #{data[:controller_name]}"
19
+ event = build_event(name, :request, data)
20
+ @logger.debug("Track action: #{JSON.pretty_generate(event)}")
21
+ event
22
+ end
23
+
24
+ def build_entity_change_event(data)
25
+ raise ArgumentError, "data must not be nil" if data.nil?
26
+ raise ArgumentError, "data must have changes" if !data.has_key?(:changes) || data[:changes].empty?
27
+ raise ArgumentError, "data must have an action_name" if !data.has_key?(:action_name) || data[:action_name].empty?
28
+ raise ArgumentError, "data must have an entity_name" if !data.has_key?(:entity_name) || data[:entity_name].empty?
29
+ name = "#{data[:action_name]} #{data[:entity_name]}"
30
+ event = build_event(name, :entity_change, data)
31
+ @logger.debug("Track change: " + JSON.pretty_generate(event))
32
+ event
33
+ end
34
+
35
+ def build_manual_event(name, data)
36
+ raise ArgumentError, "data must not be nil" if data.nil?
37
+ raise ArgumentError, "data must be a hash" if !data.is_a?(Hash)
38
+ raise ArgumentError, "name must not be nil or empty" if name.nil? || name.empty?
39
+ event = build_event(name.to_s, :manual, data)
40
+ @logger.debug("Track manual: " + JSON.pretty_generate(event))
41
+ event
42
+ end
43
+
44
+ def build_event(name, kind, data)
45
+ original_emitter = @emitter
46
+ overriden_emitter = data[:overriden_emitter]&.strip&.delete(".")
47
+ final_emitter = overriden_emitter.blank? ? original_emitter : overriden_emitter
48
+ Event.new(SecureRandom.uuid, final_emitter, kind, name, deep_camelize(data))
49
+ end
50
+
51
+ def create_data_from_entity_changes(action_name, entity, request_id, request_entity_metadata, changes, user_metadata_lambda, association_columns, ignored_model_changes, flu_overriden_emitter_lambda)
52
+ {
53
+ entity_id: entity.id,
54
+ entity_name: entity.class.name.underscore,
55
+ overriden_emitter: flu_overriden_emitter_lambda ? entity.instance_exec(&flu_overriden_emitter_lambda) : nil,
56
+ request_id: request_id,
57
+ request_metadata: request_entity_metadata.nil? ? {} : request_entity_metadata,
58
+ action_name: action_name,
59
+ changes: changes.except(*ignored_model_changes).except(*@default_ignored_model_changes),
60
+ user_metadata: user_metadata_lambda ? entity.instance_exec(&user_metadata_lambda) : {},
61
+ associations: extract_associations_from(entity, association_columns)
62
+ }
63
+ end
64
+
65
+ def create_data_from_request(request_id, params, request, response, request_start_time, ignored_request_params)
66
+ {
67
+ request_id: request_id,
68
+ controller_name: params[:controller],
69
+ action_name: params[:action],
70
+ path: request.original_fullpath,
71
+ response_code: response.status,
72
+ user_agent: request.user_agent,
73
+ duration: Time.zone.now - request_start_time,
74
+ params: params.except(*ignored_request_params).except(*@default_ignored_request_params).to_h
75
+ }
76
+ end
77
+
78
+ private
79
+
80
+ def deep_camelize(value)
81
+ case value
82
+ when Array
83
+ value.map { |v| deep_camelize v }
84
+ when Hash
85
+ value.reduce({}) do |camelized_hash, (k,v)|
86
+ camelized_hash[camelize(sanitize(k.to_s), false)] = deep_camelize v
87
+ camelized_hash
88
+ end
89
+ else
90
+ sanitize(value)
91
+ end
92
+ end
93
+
94
+ def camelize(lower_case_and_underscored_word, first_letter_in_uppercase = true)
95
+ if first_letter_in_uppercase
96
+ lower_case_and_underscored_word.to_s.gsub(/\/(.?)/) { "::" + $1.upcase }.gsub(/(^|_)(.)/) { $2.upcase }
97
+ else
98
+ (lower_case_and_underscored_word[0] || "") + camelize(lower_case_and_underscored_word)[1..-1]
99
+ end
100
+ end
101
+
102
+ def extract_associations_from(entity, association_columns)
103
+ association_columns.reduce({}) do |associations, column_name|
104
+ associations[column_name] = entity[column_name]
105
+ associations
106
+ end
107
+ end
108
+
109
+ def sanitize(value)
110
+ if value.respond_to?(:encode)
111
+ value.encode("UTF-8", invalid: :replace, undef: :replace).delete("\u0000")
112
+ else
113
+ value
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,51 @@
1
+ require "bunny"
2
+ require_relative "event"
3
+
4
+
5
+ module Flu
6
+ class EventPublisher
7
+ def initialize(configuration)
8
+ @logger = configuration.logger
9
+ @configuration = configuration
10
+ end
11
+
12
+ def publish(event, persistent=true)
13
+ routing_key = event.to_routing_key
14
+ @logger.debug("Publishing event with id '#{event.id}' with routing key: #{routing_key}")
15
+ @exchange.publish(event.to_json, routing_key: routing_key, persistent: persistent)
16
+ @logger.debug("Event published.")
17
+ end
18
+
19
+ def connect
20
+ connected = false
21
+ while !connected
22
+ begin
23
+ connect_to_exchange
24
+ connected = true
25
+ rescue Bunny::TCPConnectionFailedForAllHosts
26
+ @logger.warn("RabbitMQ connection failed, try again in 1 second.")
27
+ sleep 1
28
+ end
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def connect_to_exchange
35
+ options = {
36
+ host: @configuration.rabbitmq_host,
37
+ port: @configuration.rabbitmq_port&.to_i,
38
+ user: @configuration.rabbitmq_user,
39
+ password: @configuration.rabbitmq_password,
40
+ automatically_recover: true
41
+ }.merge(@configuration.bunny_options || {})
42
+
43
+ @connection = Bunny.new(options)
44
+ @connection.start
45
+ @channel = @connection.create_channel
46
+ @exchange = @channel.send(:topic,
47
+ @configuration.rabbitmq_exchange_name,
48
+ durable: @configuration.rabbitmq_exchange_durable)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,40 @@
1
+ require "rabbitmq/http/client"
2
+
3
+ module Flu
4
+ class QueueRepository
5
+ def initialize(configuration)
6
+ @logger = configuration.logger
7
+ @configuration = configuration
8
+ @management_client = create_management_client(configuration)
9
+ end
10
+
11
+ def find_all
12
+ @management_client.list_queues
13
+ end
14
+
15
+ def find_queue(name)
16
+ @management_client.queue_info("/", name)
17
+ end
18
+
19
+ def find_bindings_for_queue(name)
20
+ @management_client.list_queue_bindings("/", name)
21
+ end
22
+
23
+ def purge_queue(name)
24
+ @management_client.purge_queue("/", name)
25
+ end
26
+
27
+ def delete_queue(name)
28
+ @management_client.delete_queue("/", name)
29
+ end
30
+
31
+ private
32
+
33
+ def create_management_client(configuration)
34
+ rabbitmq_url = "#{configuration.rabbitmq_management_scheme || "http"}://#{configuration.rabbitmq_host}:#{configuration.rabbitmq_management_port}/"
35
+ RabbitMQ::HTTP::Client.new(rabbitmq_url,
36
+ username: configuration.rabbitmq_user,
37
+ password: configuration.rabbitmq_password)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,14 @@
1
+ module Flu
2
+ class Railtie < Rails::Railtie
3
+ railtie_name :flu
4
+
5
+ config.to_prepare do
6
+ Flu.init
7
+ Flu.start
8
+ end
9
+
10
+ config.after_initialize do
11
+ Flu.start
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,71 @@
1
+ module Flu
2
+ module Util
3
+ class ExportService
4
+ def export_existing_entities_to_events(event_publisher, event_factory, entity_types=nil)
5
+ entity_types ||= find_all_tracked_entity_types(entity_types)
6
+ check_that_all_entity_types_have_created_at(entity_types)
7
+
8
+ Flu.logger.level = Logger::WARN
9
+ total_number_of_entity_types = entity_types.size
10
+ current_entity_type_index = 0
11
+ entity_types.each do | entity_type |
12
+ entities = entity_type.all
13
+ total_number_of_entities = entities.size
14
+ current_entity_index = 0
15
+ current_entity_type_index += 1
16
+ association_columns = entity_type.flu_association_columns
17
+ user_metadata_lambda = entity_type.flu_user_metadata_lambdas[:create]
18
+ ignored_model_changes = entity_type.flu_ignored_model_changes
19
+
20
+ entities.each do |entity|
21
+ print "\r" unless current_entity_index == 0
22
+ current_entity_index += 1
23
+ print "#{entity_type} (#{current_entity_type_index}/#{total_number_of_entity_types}) : #{current_entity_index}/#{total_number_of_entities}"
24
+ data = extract_data_from(entity, event_factory, user_metadata_lambda, association_columns, ignored_model_changes)
25
+ event = event_factory.build_entity_change_event(data)
26
+ event.timestamp = entity.created_at unless entity.created_at.nil?
27
+ event_publisher.publish(event)
28
+ print "\n" if current_entity_index == total_number_of_entities
29
+ end
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def check_that_all_entity_types_have_created_at(entity_types)
36
+ entity_types_without_timestamp = entity_types.select do | entity_type |
37
+ !entity_type.attribute_names.include?("created_at")
38
+ end
39
+ if (entity_types_without_timestamp.size > 0)
40
+ raise "#{entity_types_without_timestamp.size} entities do not have 'created_at': #{entity_types_without_timestamp.map {|type| type.name}}"
41
+ end
42
+ end
43
+
44
+ def find_all_entity_types
45
+ eager_loaded = Rails.application.eager_load!
46
+ Zeitwerk::Loader.eager_load_all if !eager_loaded && defined?(Zeitwerk)
47
+ ActiveRecord::Base.descendants
48
+ end
49
+
50
+ def find_all_tracked_entity_types(entity_types)
51
+ (entity_types || find_all_entity_types).select do | entity_type |
52
+ ActiveRecord::Base.connection.table_exists?(entity_type.table_name) && entity_type.flu_is_tracked
53
+ end
54
+ end
55
+
56
+ def extract_data_from(entity, event_factory, user_metadata_lambda, association_columns, ignored_model_changes)
57
+ changes = create_changes_from_existing(entity)
58
+ event_factory.create_data_from_entity_changes(:create, entity, nil, nil, changes, user_metadata_lambda, association_columns, ignored_model_changes, nil)
59
+ end
60
+
61
+ def create_changes_from_existing(entity)
62
+ changes = entity.attribute_names.inject({}) do | result, attribute_name |
63
+ attribute_value = entity.attributes[attribute_name]
64
+ result[attribute_name.to_s] = [nil, attribute_value] unless attribute_value.nil?
65
+ result
66
+ end
67
+ changes.except(:created_at, :updated_at)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,3 @@
1
+ module Flu
2
+ VERSION = "1.2.0"
3
+ end
data/lib/flu-rails.rb ADDED
@@ -0,0 +1,88 @@
1
+ require "logger"
2
+ require "json"
3
+ require_relative "flu-rails/version"
4
+ require_relative "flu-rails/event"
5
+ require_relative "flu-rails/event_factory"
6
+ require_relative "flu-rails/queue_repository"
7
+ require_relative "flu-rails/configuration"
8
+ require_relative "flu-rails/core_ext"
9
+ require_relative "flu-rails/event_publisher"
10
+ require_relative "flu-rails/util"
11
+ require_relative "flu-rails/dummy/in_memory_event_publisher"
12
+ require_relative "flu-rails/railtie" if defined?(Rails)
13
+
14
+ module Flu
15
+ def self.configure
16
+ yield @configuration ||= Flu::Configuration.new
17
+ end
18
+
19
+ def self.config
20
+ @configuration
21
+ end
22
+
23
+ def self.logger
24
+ @logger
25
+ end
26
+
27
+ def self.event_factory
28
+ @event_factory
29
+ end
30
+
31
+ def self.event_publisher
32
+ @event_publisher
33
+ end
34
+
35
+ def self.init
36
+ raise "configuration.application_name must not be nil" if @configuration.application_name.nil?
37
+ @logger = @configuration.logger
38
+ @event_factory = Flu::EventFactory.new(@configuration)
39
+ @event_publisher = create_event_publisher(@configuration)
40
+ extend_models_and_controllers
41
+ end
42
+
43
+ def self.create_event_publisher(configuration)
44
+ if is_testing_environment?
45
+ logger.info("Loading Flu with a dummy event publisher (this will not connect any exchange)")
46
+ require_relative "flu-rails/dummy/in_memory_event_publisher"
47
+ Flu::Dummy::InMemoryEventPublisher.new(@configuration)
48
+ else
49
+ Flu::EventPublisher.new(@configuration)
50
+ end
51
+ end
52
+
53
+ def self.is_testing_environment?
54
+ defined?(Rails) && config.development_environments.include?(Rails.env)
55
+ end
56
+
57
+ def self.extend_models_and_controllers
58
+ Flu::CoreExt.extend_model_classes(@event_factory, @event_publisher)
59
+ Flu::CoreExt.extend_controller_classes(@event_factory, @event_publisher, @logger)
60
+ end
61
+
62
+ def self.start
63
+ @event_publisher.connect if config.auto_connect_to_exchange
64
+ end
65
+
66
+ def self.load_configuration
67
+ configure do |config|
68
+ config.development_environments = []
69
+ config.rejected_user_agents = []
70
+ config.logger = ::Logger.new(STDOUT)
71
+ config.rabbitmq_host = "localhost"
72
+ config.rabbitmq_port = 5672
73
+ config.rabbitmq_management_scheme = "http"
74
+ config.rabbitmq_management_port = 15672
75
+ config.rabbitmq_user = ""
76
+ config.rabbitmq_password = ""
77
+ config.rabbitmq_exchange_name = "events"
78
+ config.rabbitmq_exchange_durable = true
79
+ config.auto_connect_to_exchange = true
80
+ config.default_ignored_model_changes = [:password, :password_confirmation, :created_at, :updated_at]
81
+ config.default_ignored_request_params = [:password, :password_confirmation, :controller, :action]
82
+ config.application_name = defined?(Rails) ? Rails.application.class.module_parent_name : nil
83
+ config.bunny_options = {}
84
+ end
85
+ end
86
+
87
+ load_configuration
88
+ end