realm-core 0.7.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,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/string'
4
+ require 'realm/query_handler'
5
+ require 'realm/command_handler'
6
+ require 'realm/domain_resolver'
7
+ require 'realm/error'
8
+ require 'realm/mixins/dependency_injection'
9
+ require 'realm/persistence/repository_query_handler_adapter'
10
+
11
+ module Realm
12
+ class Dispatcher
13
+ include Mixins::DependencyInjection
14
+ inject DomainResolver
15
+
16
+ def initialize(runtime)
17
+ @runtime = runtime
18
+ @threads = []
19
+ end
20
+
21
+ def query(identifier, params = {})
22
+ callable, action = get_callable(QueryHandler, identifier)
23
+ callable, action = get_repo_adapter(identifier) unless callable
24
+ raise QueryHandlerMissing, identifier unless callable
25
+
26
+ dispatch(callable, action, params)
27
+ end
28
+
29
+ def run(identifier, params = {})
30
+ callable, action = get_callable(CommandHandler, identifier)
31
+ raise CommandHandlerMissing, identifier unless callable
32
+
33
+ dispatch(callable, action, params)
34
+ end
35
+
36
+ def run_as_job(identifier, params = {})
37
+ callable, action = get_callable(CommandHandler, identifier)
38
+ raise CommandHandlerMissing, identifier unless callable
39
+
40
+ @threads.delete_if(&:stop?)
41
+ @threads << Thread.new do # TODO: back by SQS
42
+ result = dispatch(callable, action, params)
43
+ yield result if block_given?
44
+ end
45
+ end
46
+
47
+ # Blocks until all jobs are finished. Useful mainly in tests.
48
+ def wait_for_jobs
49
+ @threads.each(&:join)
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :runtime
55
+
56
+ def dispatch(callable, action, params)
57
+ arguments = { action: action, params: params, runtime: runtime }.compact
58
+ callable.(**arguments)
59
+ end
60
+
61
+ def get_callable(type, identifier)
62
+ return [identifier, nil] if identifier.respond_to?(:call)
63
+
64
+ domain_resolver.get_handler_with_action(type, identifier)
65
+ end
66
+
67
+ def get_repo_adapter(identifier)
68
+ parts = identifier.to_s.split('.')
69
+ return [nil, nil] unless parts.size == 2 && runtime&.context&.key?("#{parts[0]}_repo")
70
+
71
+ [Persistence::RepositoryQueryHandlerAdapter.new(runtime.context["#{parts[0]}_repo"]), parts[1].to_sym]
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/string'
4
+ require 'realm/command_handler'
5
+ require 'realm/query_handler'
6
+ require 'realm/event_handler'
7
+ require 'realm/event'
8
+
9
+ module Realm
10
+ class DomainResolver
11
+ DOMAIN_CLASS_TYPES = [CommandHandler, QueryHandler, EventHandler].freeze
12
+
13
+ def initialize(domain_module = nil)
14
+ # nil domain resolver is useful in tests
15
+ @domain_module = domain_module
16
+ @index = DOMAIN_CLASS_TYPES.map { |t| [t, {}] }.to_h
17
+ scan(domain_module) if domain_module
18
+ end
19
+
20
+ def get_handler_with_action(type, identifier)
21
+ handlers = @index[type]
22
+ return [handlers[identifier], :handle] if handlers.key?(identifier)
23
+
24
+ # The last part of the identifier can action method name inside the handler
25
+ parts = identifier.split('.')
26
+ handler_part = parts[..-2].join('.')
27
+ action = parts[-1]
28
+ return [handlers[handler_part], action.to_sym] if handlers.key?(handler_part)
29
+
30
+ [nil, nil]
31
+ end
32
+
33
+ def all_event_handlers
34
+ @index[EventHandler].values
35
+ end
36
+
37
+ private
38
+
39
+ def scan(root_module)
40
+ root_module_str = root_module.to_s
41
+ root_module.constants.each do |const_sym|
42
+ const = root_module.const_get(const_sym)
43
+ next unless const.is_a?(Module) && !(const < Event) && const.to_s.start_with?(root_module_str)
44
+
45
+ type = DOMAIN_CLASS_TYPES.find { |t| const < t }
46
+ next scan(const) unless type
47
+
48
+ register(type, const)
49
+ end
50
+ end
51
+
52
+ def register(type, const)
53
+ # Remove domain module prefix and handler type suffixes
54
+ operation_type = type.to_s.demodulize.sub('Handler', '')
55
+ identifier = const.to_s.gsub(/(^#{@domain_module})|((#{operation_type})?Handlers?)/, '')
56
+ @index[type][identifier.underscore.gsub(%r{(^/+)|(/+$)}, '').gsub(%r{/+}, '.')] = const
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Realm
4
+ class Error < StandardError
5
+ def self.[](default_msg)
6
+ Class.new(Realm::Error) do
7
+ define_method(:initialize) do |msg = default_msg|
8
+ super(msg)
9
+ end
10
+ end
11
+ end
12
+ end
13
+
14
+ class QueryHandlerMissing < Error
15
+ def initialize(query_name, msg: "Cannot find handler for query '#{query_name}'")
16
+ super(msg)
17
+ end
18
+ end
19
+
20
+ class CommandHandlerMissing < Error
21
+ def initialize(command_name, msg: "Cannot find handler for command '#{command_name}'")
22
+ super(msg)
23
+ end
24
+ end
25
+
26
+ class CannotHandleAction < Error
27
+ def initialize(handler, action, msg: "#{handler} cannot handle action '#{action}'")
28
+ super(msg)
29
+ end
30
+ end
31
+
32
+ class DependencyMissing < Error
33
+ def initialize(dependency_name, msg: "Dependency '#{dependency_name}' missing in container")
34
+ super(msg)
35
+ end
36
+ end
37
+
38
+ class EventClassMissing < Error
39
+ def initialize(identifier, events_module, msg: "Cannot find event class for #{identifier} in #{events_module}")
40
+ super(msg)
41
+ end
42
+ end
43
+
44
+ class InvalidParams < Error
45
+ def initialize(validation_result, msg: "Validation failed: #{validation_result.errors.to_h}")
46
+ @validation_result = validation_result
47
+ super(msg)
48
+ end
49
+
50
+ def params
51
+ @validation_result.to_h
52
+ end
53
+
54
+ def messages
55
+ @validation_result.errors.to_h
56
+ end
57
+
58
+ def full_messages
59
+ @validation_result.errors(full: true).to_h
60
+ end
61
+ end
62
+
63
+ class UniqueConstraintError < Error; end
64
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'active_support/core_ext/hash'
5
+ require 'active_support/core_ext/string'
6
+ require 'dry-struct'
7
+ require 'realm/types'
8
+
9
+ module Realm
10
+ class Event < Dry::Struct
11
+ T = Realm::Types
12
+
13
+ transform_keys(&:to_sym)
14
+
15
+ attribute :head do
16
+ attribute :id, T::Strict::String
17
+ attribute :triggered_at, T::JSON::Time
18
+ attribute? :version, T::Coercible::String
19
+ attribute? :origin, T::Strict::String
20
+ attribute :correlation_id, T::Strict::String
21
+ attribute? :cause_event_id, T::Strict::String
22
+ attribute? :cause, T::Strict::String
23
+ end
24
+
25
+ class << self
26
+ def new(attributes = {})
27
+ head = {
28
+ id: SecureRandom.uuid,
29
+ correlation_id: SecureRandom.uuid,
30
+ triggered_at: Time.now,
31
+ version: 1, # until we need breaking change (anything except adding attribute) all events are version 1
32
+ }.merge(attributes.fetch(:head, {}))
33
+ body = attributes[:body] || attributes.except(:head)
34
+ super({ head: head }.merge(body.empty? ? {} : { body: body }))
35
+ end
36
+
37
+ def type
38
+ @type ||= name.demodulize.sub('Event', '').underscore
39
+ end
40
+
41
+ protected
42
+
43
+ def body_struct(&block)
44
+ attribute(:body, &block)
45
+ end
46
+ end
47
+
48
+ def type
49
+ self.class.type
50
+ end
51
+
52
+ def to_json(*args)
53
+ JSON.generate(to_h, *args)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'realm/error'
4
+ require 'realm/event'
5
+
6
+ module Realm
7
+ class EventFactory
8
+ def initialize(events_module)
9
+ @events_module = events_module
10
+ end
11
+
12
+ def create_event(event_type, correlate: nil, cause: nil, **attributes)
13
+ head = enhance_head(attributes.fetch(:head, {}), correlate: correlate, cause: cause)
14
+ body = attributes.fetch(:body, attributes.except(:head))
15
+
16
+ event_class_for(event_type).new(head: head, body: body)
17
+ end
18
+
19
+ def event_class_for(event_type)
20
+ return event_type if event_type.respond_to?(:new)
21
+
22
+ class_name = "#{@events_module}::#{event_type.to_s.camelize}"
23
+ klass = class_name.safe_constantize || "#{class_name}Event".safe_constantize
24
+ return klass if klass
25
+
26
+ raise EventClassMissing.new(event_type, @events_module)
27
+ end
28
+
29
+ private
30
+
31
+ def enhance_head(head, correlate:, cause:)
32
+ head[:correlation_id] = correlate.head.correlation_id if correlate
33
+ if cause.is_a?(Event)
34
+ head[:cause_event_id] = cause.head.id
35
+ head[:correlation_id] ||= cause.head.correlation_id
36
+ elsif cause
37
+ head[:cause] = cause
38
+ end
39
+ head
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/string'
4
+ require 'realm/error'
5
+ require 'realm/mixins/context_injection'
6
+ require 'realm/mixins/reactive'
7
+ require 'realm/mixins/repository_helper'
8
+ require 'realm/mixins/aggregate_member'
9
+
10
+ module Realm
11
+ class EventHandler
12
+ extend Mixins::ContextInjection::ClassMethods
13
+ include Mixins::AggregateMember
14
+ include Mixins::Reactive
15
+ include Mixins::RepositoryHelper
16
+
17
+ class RuntimeBound
18
+ delegate :identifier, :event_types, to: :@handler_class
19
+
20
+ def initialize(handler_class, runtime)
21
+ @handler_class = handler_class
22
+ @runtime = runtime
23
+ end
24
+
25
+ def call(event)
26
+ @handler_class.(event, runtime: @runtime.session(cause: event))
27
+ end
28
+ end
29
+
30
+ class << self
31
+ attr_reader :trigger_mapping, :event_namespace
32
+
33
+ def bind_runtime(runtime)
34
+ RuntimeBound.new(self, runtime)
35
+ end
36
+
37
+ def call(event, runtime:)
38
+ new(runtime: runtime).(event)
39
+ end
40
+
41
+ def identifier(value = :undefined)
42
+ if value == :undefined
43
+ defined?(@identifier) ? @identifier : name.gsub(/(Domain|EventHandlers?)/, '').underscore.gsub(%r{/+}, '-')
44
+ else
45
+ @identifier = value
46
+ end
47
+ end
48
+
49
+ def event_types
50
+ defined?(@trigger_mapping) ? @trigger_mapping.keys.uniq : []
51
+ end
52
+
53
+ protected
54
+
55
+ def namespace(value)
56
+ @event_namespace = value
57
+ end
58
+
59
+ def on(*triggers, run: nil, **options, &block)
60
+ @method_triggers = triggers
61
+ @method_trigger_options = options # TODO: store and pass to gateway
62
+ return unless run || block
63
+
64
+ block = ->(event) { self.run(run, event.body) } if run
65
+ define_method("handle_#{triggers.join('_or_')}", &block)
66
+ end
67
+
68
+ def method_added(method_name)
69
+ super
70
+ return unless defined?(@method_triggers)
71
+
72
+ @trigger_mapping ||= {}
73
+ @method_triggers.each do |trigger|
74
+ (@trigger_mapping[trigger.to_sym] ||= []) << method_name
75
+ end
76
+ remove_instance_variable(:@method_triggers)
77
+ end
78
+ end
79
+
80
+ def initialize(runtime: nil)
81
+ @runtime = runtime
82
+ end
83
+
84
+ def call(event)
85
+ event_to_methods(event).each do |method_name|
86
+ send(method_name, event)
87
+ rescue Realm::UniqueConstraintError => e
88
+ context[:logger]&.warn(e.full_message)
89
+ end
90
+ end
91
+
92
+ protected
93
+
94
+ delegate :context, to: :@runtime
95
+
96
+ private
97
+
98
+ def event_to_methods(event)
99
+ self.class.trigger_mapping.fetch_values(event.type.to_sym, :any) { [] }.flatten
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/string'
4
+ require 'active_support/core_ext/hash'
5
+ require 'realm/error'
6
+ require 'realm/domain_resolver'
7
+ require 'realm/event_handler'
8
+ require 'realm/event_factory'
9
+ require 'realm/mixins/dependency_injection'
10
+ require_relative 'event_router/internal_loop_gateway'
11
+
12
+ module Realm
13
+ class EventRouter
14
+ include Mixins::DependencyInjection
15
+ inject DomainResolver
16
+ inject 'Realm::Runtime', lazy: true
17
+
18
+ def initialize(gateways_spec, prefix: nil)
19
+ @prefix = prefix
20
+ @auto_registered = false
21
+ @default_namespace = nil
22
+ init_gateways(gateways_spec)
23
+ end
24
+
25
+ def register(handler_class)
26
+ gateway_for(handler_class.try(:event_namespace)).register(handler_class)
27
+ end
28
+
29
+ def add_listener(event_type, listener, namespace: nil)
30
+ gateway_for(namespace).add_listener(event_type, listener)
31
+ end
32
+
33
+ def trigger(identifier, attributes = {})
34
+ namespace, event_type = identifier.to_s.include?('.') ? identifier.split('.') : [nil, identifier]
35
+ gateway_for(namespace).trigger(event_type, attributes)
36
+ end
37
+
38
+ def workers(*namespaces, **options)
39
+ auto_register_handlers
40
+ @gateways.filter_map do |(namespace, gateway)|
41
+ gateway.worker(**options) if namespaces.empty? || namespaces.include?(namespace)
42
+ end
43
+ end
44
+
45
+ def active_queues
46
+ auto_register_handlers
47
+ @gateways.values.reduce([]) do |queues, gateway|
48
+ queues + gateway.queues
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def init_gateways(gateways_spec)
55
+ auto_register_on_init = false
56
+ @gateways = gateways_spec.each_with_object({}) do |(namespace, config), gateways|
57
+ gateway_class = gateway_class(config.fetch(:type))
58
+ auto_register_on_init ||= gateway_class.auto_register_on_init
59
+ gateways[namespace] = instantiate_gateway(namespace, gateway_class, config)
60
+ @default_namespace = namespace if config[:default]
61
+ end
62
+ auto_register_handlers if auto_register_on_init
63
+ end
64
+
65
+ def gateway_class(type)
66
+ return InternalLoopGateway if type.to_s == 'internal_loop'
67
+
68
+ runtime.container.resolve("event_router.gateway_classes.#{type}")
69
+ end
70
+
71
+ def instantiate_gateway(namespace, klass, config)
72
+ klass.new(
73
+ namespace: namespace,
74
+ queue_prefix: @prefix,
75
+ event_factory: EventFactory.new(config.fetch(:events_module)),
76
+ runtime: runtime,
77
+ **config.except(:type, :default, :events_module),
78
+ )
79
+ end
80
+
81
+ def auto_register_handlers
82
+ return if @auto_registered || !domain_resolver
83
+
84
+ @auto_registered = true
85
+ domain_resolver.all_event_handlers.each { |klass| register(klass) }
86
+ end
87
+
88
+ def gateway_for(namespace)
89
+ @gateways.fetch(namespace.try(:to_sym) || default_namespace) do
90
+ raise "No event gateway for #{namespace || 'default'} namespace" # TODO: extract error class
91
+ end
92
+ end
93
+
94
+ def default_namespace
95
+ return @default_namespace if @default_namespace
96
+
97
+ @gateways.keys[0] if @gateways.keys.size == 1
98
+ end
99
+ end
100
+ end