omnes 0.1.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.
data/lib/omnes/bus.rb ADDED
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "omnes/publication"
4
+ require "omnes/registry"
5
+ require "omnes/subscription"
6
+ require "omnes/unstructured_event"
7
+
8
+ module Omnes
9
+ # An event bus for the publish/subscribe pattern
10
+ #
11
+ # An instance of this class acts as an event bus middleware for publishers of
12
+ # events and their subscriptions.
13
+ #
14
+ # ```
15
+ # bus = Omnes::Bus.new
16
+ # ```
17
+ #
18
+ # Before being able to work with a given event, its name (a {Symbol}) needs to
19
+ # be registered:
20
+ #
21
+ # ```
22
+ # bus.register(:foo)
23
+ # ```
24
+ #
25
+ # An event can be anything responding to a method `:name` which, needless to
26
+ # say, must match with a registered name.
27
+ #
28
+ # Typically, there're two main ways to generate events.
29
+ #
30
+ # An event can be generated at publication time, where you provide its name
31
+ # and a payload to be consumed by its subscribers.
32
+ #
33
+ # ```
34
+ # bus.publish(:foo, bar: :baz)
35
+ # ```
36
+ #
37
+ # In that case, an instance of {Omnes::UnstructuredEvent} is generated
38
+ # under the hood.
39
+ #
40
+ # Unstructured events are straightforward to create and use, but they're
41
+ # harder to debug as they're defined at publication time. On top of that,
42
+ # other features, such as event persistence, can't be reliably built on top of
43
+ # them.
44
+ #
45
+ # You can also publish an instance of a class including {Omnes::Event}. The
46
+ # only fancy thing it provides is an OOTB event name generated based on the
47
+ # class name. See {Omnes::Event} for details.
48
+ #
49
+ # ```
50
+ # class Foo
51
+ # include Omnes::Event
52
+ #
53
+ # attr_reader :bar
54
+ #
55
+ # def initialize
56
+ # @bar = :baz
57
+ # end
58
+ # end
59
+ #
60
+ # bus.publish(Foo.new)
61
+ # ```
62
+ #
63
+ # Instance-backed events provide a well-defined structure, and other features,
64
+ # like event persistence, can be added on top of them.
65
+ #
66
+ # Regardless of the type of published event, it's yielded to its subscriptions
67
+ # so that they can do their job:
68
+ #
69
+ # ```
70
+ # bus.subscribe(:foo) do |event|
71
+ # # event.payload[:bar] or event[:bar] for unstructured events
72
+ # # event.bar for the event instance example
73
+ # end
74
+ # ```
75
+ #
76
+ # The subscription code can be given as a block (previous example) or as
77
+ # anything responding to a method `#call`.
78
+ #
79
+ # ```
80
+ # class MySubscription
81
+ # def call(event)
82
+ # # ...
83
+ # end
84
+ # end
85
+ #
86
+ # bus.subscribe(:foo, MySubscription.new)
87
+ # ```
88
+ #
89
+ # See also {Omnes::Subscriber} for a more powerful way to define standalone
90
+ # event handlers.
91
+ #
92
+ # You can also create a subscription that will run for all events:
93
+ #
94
+ # ```
95
+ # bus.subscribe_to_all(MySubscription.new)
96
+ # ```
97
+ #
98
+ # Custom matchers can be defined. A matcher is something responding to `#call`
99
+ # and taking the event as an argument. It needs to return `true` or `false` to
100
+ # decide whether the subscription needs to be run for that event.
101
+ #
102
+ # ```
103
+ # matcher ->(event) { event.name.start_with?(:foo) }
104
+ #
105
+ # bus.subscribe_with_matcher(matcher, MySubscription.new)
106
+ # ```
107
+ class Bus
108
+ # @api private
109
+ def self.EventType(value, **payload)
110
+ case value
111
+ when Symbol
112
+ UnstructuredEvent.new(omnes_event_name: value, payload: payload)
113
+ else
114
+ value
115
+ end
116
+ end
117
+
118
+ # @api private
119
+ attr_reader :cal_loc_start,
120
+ :subscriptions
121
+
122
+ # @!attribute [r] registry
123
+ # @return [Omnes::Bus::Registry]
124
+ attr_reader :registry
125
+
126
+ def initialize(cal_loc_start: 1, registry: Registry.new, subscriptions: [])
127
+ @cal_loc_start = cal_loc_start
128
+ @registry = registry
129
+ @subscriptions = subscriptions
130
+ end
131
+
132
+ # Registers an event name
133
+ #
134
+ # @param event_name [Symbol]
135
+ # @param caller_location [Thread::Backtrace::Location] Caller location
136
+ # associated to the registration. Useful for debugging (shown in error
137
+ # messages). It defaults to this method's caller.
138
+ #
139
+ # @raise [Omnes::AlreadyRegisteredEventError] when the event is already
140
+ # registered
141
+ # @raise [Omnes::InvalidEventNameError] when the event is not a {Symbol}
142
+ #
143
+ # @return [Omnes::Registry::Registration]
144
+ def register(event_name, caller_location: caller_locations(cal_loc_start)[0])
145
+ registry.register(event_name, caller_location: caller_location)
146
+ end
147
+
148
+ # Publishes an event, running all matching subscriptions
149
+ #
150
+ # @overload publish(event_name, caller_location:, **payload)
151
+ # @param event_name [Symbol] Name for the generated
152
+ # {Omnes::UnstructuredEvent} event.
153
+ # @param **payload [Hash] Payload for the generated
154
+ # {Omnes::UnstrUnstructuredEvent}
155
+ #
156
+ # @overload publish(event, caller_location:)
157
+ # @param event [#name] An event instance
158
+ #
159
+ # @param caller_location [Thread::Backtrace::Location] Caller location
160
+ # associated to the publication. Useful for debugging (shown in error
161
+ # messages). It defaults to this method's caller.
162
+ #
163
+ # @return [Omnes::Publication] A publication object encapsulating metadata
164
+ # for the event and the originated subscription executions
165
+ #
166
+ # @raise [Omnes::UnknownEventError] When event name has not been registered
167
+ def publish(event, caller_location: caller_locations(cal_loc_start)[0], **payload)
168
+ publication_time = Time.now.utc
169
+ event = self.class.EventType(event, **payload)
170
+ registry.check_event_name(event.omnes_event_name)
171
+ executions = execute_subscriptions_for_event(event)
172
+
173
+ Publication.new(
174
+ event: event,
175
+ executions: executions,
176
+ caller_location: caller_location,
177
+ time: publication_time
178
+ )
179
+ end
180
+
181
+ # Adds a subscription for a single event
182
+ #
183
+ # @param event_name [Symbol] Name of the event
184
+ # @param callable [#call] Subscription callback taking the event
185
+ # @yield [event] Subscription callback if callable is not given
186
+ #
187
+ # @return [Omnes::Subscription]
188
+ #
189
+ # @raise [Omnes::UnknownEventError] When event name has not been registered
190
+ def subscribe(event_name, callable = nil, &block)
191
+ registry.check_event_name(event_name)
192
+
193
+ subscribe_with_matcher(Subscription::SINGLE_EVENT_MATCHER.curry[event_name], callable, &block)
194
+ end
195
+
196
+ # Adds a subscription for all events
197
+ #
198
+ # @param callable [#call] Subscription callback taking the event
199
+ # @yield [event] Subscription callback if callable is not given
200
+ #
201
+ # @return [Omnes::Subscription]
202
+ def subscribe_to_all(callable = nil, &block)
203
+ subscribe_with_matcher(Subscription::ALL_EVENTS_MATCHER, callable, &block)
204
+ end
205
+
206
+ # Adds a subscription with given matcher
207
+ #
208
+ # @param matcher [#call] Callable taking the event and returning a boolean
209
+ # @param callable [#call] Subscription callback taking the event
210
+ # @yield [event] Subscription callback if callable is not given
211
+ #
212
+ # @return [Omnes::Subscription]
213
+ def subscribe_with_matcher(matcher, callable = nil, &block)
214
+ callback = callable || block
215
+ Subscription.new(matcher: matcher, callback: callback).tap do |subscription|
216
+ @subscriptions << subscription
217
+ end
218
+ end
219
+
220
+ # Removes a subscription
221
+ #
222
+ # @param subscription [Omnes::Subscription]
223
+ def unsubscribe(subscription)
224
+ @subscriptions.delete(subscription)
225
+ end
226
+
227
+ # Runs given block performing only a selection of subscriptions
228
+ #
229
+ # That's something useful for testing purposes, as it allows to silence
230
+ # subscriptions that are not part of the system under test.
231
+ #
232
+ # After the block is over, original subscriptions are restored.
233
+ #
234
+ # @param selection [Array<Omnes::Subscription>]
235
+ # @yield Block to run
236
+ #
237
+ # @raise [Omnes::UnknownSubscriptionError] when the subscription is not
238
+ # known by the bus
239
+ def performing_only(*selection)
240
+ selection.each do |subscription|
241
+ unless subscriptions.include?(subscription)
242
+ raise UnknownSubscriptionError.new(subscription: subscription,
243
+ bus: self)
244
+ end
245
+ end
246
+ all_subscriptions = subscriptions
247
+ @subscriptions = selection
248
+ yield
249
+ ensure
250
+ @subscriptions = all_subscriptions
251
+ end
252
+
253
+ # Specialized version of {#performing_only} with no subscriptions
254
+ #
255
+ # @see #performing_only
256
+ def performing_nothing(&block)
257
+ performing_only(&block)
258
+ end
259
+
260
+ private
261
+
262
+ def execute_subscriptions_for_event(event)
263
+ subscriptions_for_event(event).map do |subscription|
264
+ subscription.(event)
265
+ end
266
+ end
267
+
268
+ def subscriptions_for_event(event_name)
269
+ @subscriptions.select do |subscription|
270
+ subscription.matches?(event_name)
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnes
4
+ class Error < StandardError; end
5
+
6
+ # Raised when an event name is not known
7
+ class UnknownEventError < Error
8
+ attr_reader :event_name, :known_events
9
+
10
+ def initialize(event_name:, known_events:)
11
+ @event_name = event_name
12
+ @known_events = known_events
13
+ super(default_message)
14
+ end
15
+
16
+ private
17
+
18
+ def default_message
19
+ <<~MSG
20
+ '#{event_name}' event is not registered.
21
+ #{suggestions_message}
22
+
23
+ All known events are:
24
+
25
+ '#{known_events.join("', '")}'
26
+ MSG
27
+ end
28
+
29
+ def suggestions_message
30
+ DidYouMean::PlainFormatter.new.message_for(suggestions)
31
+ end
32
+
33
+ def suggestions
34
+ dictionary = DidYouMean::SpellChecker.new(dictionary: known_events)
35
+
36
+ dictionary.correct(event_name)
37
+ end
38
+ end
39
+
40
+ # Raised when trying to register an invalid event name
41
+ class InvalidEventNameError < Error
42
+ attr_reader :event_name
43
+
44
+ def initialize(event_name:)
45
+ @event_name = event_name
46
+ super(default_message)
47
+ end
48
+
49
+ private
50
+
51
+ def default_message
52
+ <<~MSG
53
+ #{event_name.inspect} is not a valid event name. Only symbols can be
54
+ registered.
55
+ MSG
56
+ end
57
+ end
58
+
59
+ # Raised when trying to register the same event name a second time
60
+ class AlreadyRegisteredEventError < Error
61
+ attr_reader :event_name, :registration
62
+
63
+ def initialize(event_name:, registration:)
64
+ @event_name = event_name
65
+ @registration = registration
66
+ super(default_message)
67
+ end
68
+
69
+ private
70
+
71
+ def default_message
72
+ <<~MSG
73
+ Can't register #{event_name} event as it's already registered.
74
+
75
+ The registration happened at:
76
+
77
+ #{registration.caller_location}
78
+ MSG
79
+ end
80
+ end
81
+
82
+ # Raised when a subscription is not known by a bus
83
+ class UnknownSubscriptionError < Error
84
+ attr_reader :subscription, :bus
85
+
86
+ def initialize(subscription:, bus:)
87
+ @subscription = subscription
88
+ @bus = bus
89
+ super(default_message)
90
+ end
91
+
92
+ private
93
+
94
+ def default_message
95
+ <<~MSG
96
+ #{subscription.inspect} is not a subscription known by bus
97
+ #{bus.inspect}
98
+ MSG
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/configurable"
4
+
5
+ module Omnes
6
+ # Event mixin for custom classes
7
+ #
8
+ # Any instance of a class including this one can be used as a published event
9
+ # (see {Omnes::Bus#publish}).
10
+ #
11
+ # ```
12
+ # class MyEvent
13
+ # include Omnes::Event
14
+ #
15
+ # attr_reader :event
16
+ #
17
+ # def initialize(id:)
18
+ # @id = id
19
+ # end
20
+ # end
21
+ #
22
+ # bus = Omnes::Bus.new
23
+ # bus.register(:my_event)
24
+ # bus.subscribe(:my_event) do |event|
25
+ # puts event.id
26
+ # end
27
+ # bus.publish(MyEvent.new(1))
28
+ # ```
29
+ module Event
30
+ extend Dry::Configurable
31
+
32
+ # Generates the event name for an event instance
33
+ #
34
+ # It returns the underscored class name, with an `Event` suffix removed if
35
+ # present. E.g:
36
+ #
37
+ # - Foo -> `:foo`
38
+ # - FooEvent -> `:foo`
39
+ # - FooBar -> `:foo_bar`
40
+ # - FBar -> `:f_bar`
41
+ # - Foo::Bar -> `:foo_bar`
42
+ #
43
+ # You can also use your custom name builder. It needs to be something
44
+ # callable taking the instance as argument and returning a {Symbol}:
45
+ #
46
+ # ```
47
+ # my_name_builder = ->(instance) { instance.class.name.to_sym }
48
+ # Omnes.config.event.name_builder = my_name_builder
49
+ # ```
50
+ #
51
+ # @return [Symbol]
52
+ DEFAULT_NAME_BUILDER = lambda do |instance|
53
+ instance.class.name
54
+ .chomp("Event")
55
+ .gsub(/([[:alpha:]])([[:upper:]])/, '\1_\2')
56
+ .gsub("::", "_")
57
+ .downcase
58
+ .to_sym
59
+ end
60
+
61
+ setting :name_builder, default: DEFAULT_NAME_BUILDER
62
+
63
+ # Event name
64
+ #
65
+ # @return [Symbol]
66
+ #
67
+ # @see DEFAULT_NAME_BUILDER
68
+ def omnes_event_name
69
+ Omnes::Event.config.name_builder.(self)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnes
4
+ # Execution of an {Omnes::Subscription}
5
+ #
6
+ # When an event is published, it executes all matching subscriptions. Every
7
+ # single execution is represented as an instance of this class. It contains
8
+ # the result value of the subscriptions along with helpful metadata as the
9
+ # time of the execution or a benchmark for it.
10
+ #
11
+ # You'll most likely interact with this class for debugging or logging
12
+ # purposes through a {Omnes::Publication} returned on {Omnes::Bus#publish}.
13
+ class Execution
14
+ # The subscription to which the execution belongs
15
+ #
16
+ # @return [Omnes::Subscription]
17
+ attr_reader :subscription
18
+
19
+ # The value returned by the {#subscription}'s callback
20
+ #
21
+ # @return [Any]
22
+ attr_reader :result
23
+
24
+ # Benchmark for the {#subscription}'s callback
25
+ #
26
+ # @return [Benchmark::Tms]
27
+ attr_reader :benchmark
28
+
29
+ # Time of execution
30
+ #
31
+ # @return [Time]
32
+ attr_reader :time
33
+
34
+ # @private
35
+ def initialize(subscription:, result:, benchmark:, time: Time.now.utc)
36
+ @subscription = subscription
37
+ @result = result
38
+ @benchmark = benchmark
39
+ @time = time
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnes
4
+ # The result of publishing an event
5
+ #
6
+ # It encapsulates a published {Omnes::Event} as well as the
7
+ # {Omnes::Execution}s it originated.
8
+ #
9
+ # This class is useful mainly for debugging and logging purposes. An
10
+ # instance of it is returned on {Omnes::Bus#publish}.
11
+ class Publication
12
+ # Published event
13
+ #
14
+ # @return [#name]
15
+ attr_reader :event
16
+
17
+ # Subscription executions that the publication originated
18
+ #
19
+ # @return [Array<Omnes::Execution>]
20
+ attr_reader :executions
21
+
22
+ # Location for the event caller
23
+ #
24
+ # It's usually set by {Omnes::Bus#publish}, and it points to the caller of
25
+ # that method.
26
+ #
27
+ # @return [Thread::Backtrace::Location]
28
+ attr_reader :caller_location
29
+
30
+ # Time of the event publication
31
+ #
32
+ # @return [Time]
33
+ attr_reader :time
34
+
35
+ # @api private
36
+ def initialize(event:, executions:, caller_location:, time:)
37
+ @event = event
38
+ @executions = executions
39
+ @caller_location = caller_location
40
+ @time = time
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "omnes/errors"
4
+
5
+ module Omnes
6
+ # Registry of known event names
7
+ #
8
+ # Before publishing or subscribing to an event, its name must be registered to
9
+ # the instance associated with the bus (see {Omnes::Bus#register}).
10
+ class Registry
11
+ # Wraps the registration of an event
12
+ class Registration
13
+ # @!attribute [r] event_name
14
+ # @return [Symbol]
15
+ attr_reader :event_name
16
+
17
+ # @!attribute [r] caller_location
18
+ # @return [Thread::Backtrace::Location]
19
+ attr_reader :caller_location
20
+
21
+ def initialize(event_name:, caller_location:)
22
+ @event_name = event_name
23
+ @caller_location = caller_location
24
+ end
25
+ end
26
+
27
+ # @!attribute [r] registrations
28
+ # @return [Array<Omnes::Registry::Registration>]
29
+ attr_reader :registrations
30
+
31
+ def initialize(registrations: [])
32
+ @registrations = registrations
33
+ end
34
+
35
+ # @api private
36
+ def register(event_name, caller_location: caller_locations(1)[0])
37
+ raise InvalidEventNameError.new(event_name: event_name) unless valid_event_name?(event_name)
38
+
39
+ registration = registration(event_name)
40
+ raise AlreadyRegisteredEventError.new(event_name: event_name, registration: registration) if registration
41
+
42
+ Registration.new(event_name: event_name, caller_location: caller_location).tap do |reg|
43
+ @registrations << reg
44
+ end
45
+ end
46
+
47
+ # Removes an event name from the registry
48
+ #
49
+ # @param event_name [Symbol]
50
+ def unregister(event_name)
51
+ check_event_name(event_name)
52
+
53
+ @registrations.delete_if { |regs| regs.event_name == event_name }
54
+ end
55
+
56
+ # Returns an array with all registered event names
57
+ #
58
+ # @return [Array<Symbol>]
59
+ def event_names
60
+ registrations.map(&:event_name)
61
+ end
62
+
63
+ # Returns the registration, if present, for the event name
64
+ #
65
+ # @param event_name [Symbol]
66
+ #
67
+ # @return [Omnes::Registry::Registration, nil]
68
+ def registration(event_name)
69
+ registrations.find { |reg| reg.event_name == event_name }
70
+ end
71
+
72
+ # Returns whether a given event name is registered
73
+ #
74
+ # Use {#check_event_name} for a raising version of it.
75
+ #
76
+ # @param event_name [Symbol]
77
+ #
78
+ # @return [Boolean]
79
+ def registered?(event_name)
80
+ !registration(event_name).nil?
81
+ end
82
+
83
+ # Checks whether given event name is present in the registry
84
+ #
85
+ # Use {#registered?} for a predicate version of it.
86
+ #
87
+ # @param event_name [Symbol]
88
+ #
89
+ # @raise [UnknownEventError] if the event is not registered
90
+ def check_event_name(event_name)
91
+ return if registered?(event_name)
92
+
93
+ raise UnknownEventError.new(event_name: event_name, known_events: event_names)
94
+ end
95
+
96
+ private
97
+
98
+ def valid_event_name?(event_name)
99
+ event_name.is_a?(Symbol)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/configurable"
4
+
5
+ module Omnes
6
+ module Subscriber
7
+ module Adapter
8
+ # [ActiveJob](https://edgeguides.rubyonrails.org/active_job_basics.html) adapter
9
+ #
10
+ # Builds subscription to be processed as ActiveJob background jobs.
11
+ #
12
+ # ActiveJob requires that the argument passed to `#perform` is
13
+ # serializable. By default, the result of calling `#payload` in the event
14
+ # is taken.
15
+ #
16
+ # ```
17
+ # class MyJob < ActiveJob
18
+ # include Omnes::Subscriber
19
+ #
20
+ # handle :my_event, with: Adapter::ActiveJob
21
+ #
22
+ # def perform(payload)
23
+ # # do_something
24
+ # end
25
+ # end
26
+ #
27
+ # bus = Omnes::Bus.new
28
+ # bus.register(:my_event)
29
+ # bus.publish(:my_event, "foo" => "bar")
30
+ # ```
31
+ # However, you can configure how the event is serialized thanks to the
32
+ # `serializer:` option. It needs to be something callable taking the
33
+ # event as argument:
34
+ #
35
+ # ```
36
+ # handle :my_event, with: Adapter::ActiveJob[serializer: :serialized_payload.to_proc]
37
+ # ```
38
+ #
39
+ # You can also globally configure the default serializer:
40
+ #
41
+ # ```
42
+ # Omnes.config.subscriber.adapter.active_job.serializer = :serialized_payload.to_proc
43
+ # ```
44
+ module ActiveJob
45
+ extend Dry::Configurable
46
+
47
+ setting :serializer, default: :payload.to_proc
48
+
49
+ # @param serializer [#call]
50
+ def self.[](serializer: config.serializer)
51
+ Instance.new(serializer: serializer)
52
+ end
53
+
54
+ # @api private
55
+ def self.call(instance, event)
56
+ self.[].(instance, event)
57
+ end
58
+
59
+ # @api private
60
+ class Instance
61
+ attr_reader :serializer
62
+
63
+ def initialize(serializer:)
64
+ @serializer = serializer
65
+ end
66
+
67
+ def call(instance, event)
68
+ instance.class.perform_later(serializer.(event))
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end