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/README.md ADDED
@@ -0,0 +1,617 @@
1
+ # Omnes
2
+
3
+ Pub/sub for Ruby.
4
+
5
+ Omnes is a Ruby library implementing the publish-subscribe pattern. This
6
+ pattern allows senders of messages to be decoupled from their receivers. An
7
+ Event Bus acts as a middleman where events are published while interested
8
+ parties can subscribe to them.
9
+
10
+ ## Installation
11
+
12
+ `bundle add omnes`
13
+
14
+ ## Usage
15
+
16
+ There're two ways to make use of the pub/sub features Omnes provides:
17
+
18
+ - Standalone, through an [`Omnes::Bus`](lib/omnes/bus.rb) instance:
19
+
20
+ ```ruby
21
+ require "omnes"
22
+
23
+ bus = Omnes::Bus.new
24
+ ```
25
+
26
+ - Mixing in the behavior in another class by including the [`Omnes`](lib/omnes.rb) module.
27
+
28
+ ```ruby
29
+ require "omnes"
30
+
31
+ class Notifier
32
+ include Omnes
33
+ end
34
+ ```
35
+
36
+ The following examples will use the direct `Omnes::Bus` instance. The only
37
+ difference for the mixing use case is that the methods are directly called in
38
+ the including instance.
39
+
40
+ ### Registering events
41
+
42
+ Before being able to work with a given event, its name (which must be a
43
+ `Symbol`) must be registered:
44
+
45
+ ```ruby
46
+ bus.register(:order_created)
47
+ ```
48
+
49
+ ### Publishing events
50
+
51
+ An event can be anything responding to a method `:name`, which must match with a
52
+ registered name.
53
+
54
+ Typically, there're two main ways to generate events.
55
+
56
+ 1. Unstructured events
57
+
58
+ An event can be generated at publication time, where you provide its name and a
59
+ payload to be consumed by its subscribers:
60
+
61
+ ```ruby
62
+ bus.publish(:order_created, number: order.number, user_email: user.email)
63
+ ```
64
+
65
+ In that case, an instance of [`Omnes::UnstructuredEvent`](lib/omnes/unstructured_event.rb) is generated
66
+ under the hood.
67
+
68
+ Unstructured events are straightforward to create and use, but they're harder
69
+ to debug as they're defined at publication time. On top of that, other
70
+ features, such as event persistence, can't be reliably built on top of them.
71
+
72
+ 2. Instance-backed events
73
+
74
+ You can also publish an instance of a class including
75
+ [`Omnes::Event`](lib/omnes/event.rb). The only fancy thing it provides is an
76
+ OOTB event name generated based on the class name. In fact, you can use
77
+ anything responding to `#omnes_event_name`.
78
+
79
+ ```ruby
80
+ class OrderCreatedEvent
81
+ include Omnes::Event
82
+
83
+ attr_reader :number, :user_email
84
+
85
+ def initialize(number:, user_email:)
86
+ @number = number
87
+ @user_email = user_email
88
+ end
89
+ end
90
+
91
+ event = OrderCreatedEvent.new(number: order.number, user_email: user.email)
92
+ bus.publish(event)
93
+ ```
94
+
95
+ By default, an event name instance equals the event class name downcased,
96
+ underscored and with the `Event` suffix removed if present (`:order_created` in
97
+ the previous example). However, you can configure your own name generator based
98
+ on the event instance:
99
+
100
+ ```ruby
101
+ event_name_as_class = ->(event) { event.class.name.to_sym } # :OrderCreatedEvent in the example
102
+ Omnes.config.event.name_builder = event_name_as_class
103
+ ```
104
+
105
+ Instance-backed events provide a well-defined structure, and other features,
106
+ like event persistence, can be added on top of them.
107
+
108
+ ### Subscribing to events
109
+
110
+ You can subscribe to a specific event to run some code whenever it's published.
111
+ The event is yielded to the subscription block:
112
+
113
+ ```ruby
114
+ bus.subscribe(:order_created) do |event|
115
+ # ...
116
+ end
117
+ ```
118
+
119
+ For unstructured events, the published data is made available through the
120
+ `payload` method, although `#[]` can be used as a shortcut:
121
+
122
+ ```ruby
123
+ bus.subscribe(:order_created) do |event|
124
+ OrderCreationEmail.new.send(number: event[:number], email: event[:user_email])
125
+ # OrderCreationEmail.new.send(number: event.payload[:number], email: event.payload[:user_email])
126
+ end
127
+ ```
128
+
129
+ Otherwise, use the event instance according to its structure:
130
+
131
+ ```ruby
132
+ bus.subscribe(:order_created) do |event|
133
+ OrderCreationEmail.new.send(number: event.number, email: event.user_email)
134
+ end
135
+ ```
136
+
137
+ The subscription code can also be given as anything responding to a method
138
+ `#call`.
139
+
140
+ ```ruby
141
+ class OrderCreationEmailSubscription
142
+ def call(event)
143
+ OrderCreationEmail.new.send(number: event.number, email: event.user_email)
144
+ end
145
+ end
146
+
147
+ bus.subscribe(:order_created, OrderCreationEmailSubscription.new)
148
+ ```
149
+
150
+ However, see [Event subscribers](#event-subscribers) section bellow for a more powerful way
151
+ to define standalone event handlers.
152
+
153
+ #### Global subscriptions
154
+
155
+ You can also create a subscription that will run for all events:
156
+
157
+ ```ruby
158
+ class LogEventsSubscription
159
+ attr_reader :logger
160
+
161
+ def initialize(logger: Logger.new(STDOUT))
162
+ @logger = logger
163
+ end
164
+
165
+ def call(event)
166
+ logger.info("Event #{event.name} published")
167
+ end
168
+ end
169
+
170
+ bus.subscribe_to_all(LogEventsSubscription.new)
171
+ ```
172
+
173
+ #### Custom matcher subscriptions
174
+
175
+ Custom event matchers can be defined. A matcher is something responding to
176
+ `#call` and taking the event as an argument. It must return `true` or `false`
177
+ to match or ignore the event.
178
+
179
+ ```ruby
180
+ ORDER_EVENTS_MATCHER = ->(event) { event.name.start_with?(:order) }
181
+
182
+ bus.subscribe_with_matcher(ORDER_EVENTS_MATCHER) do |event|
183
+ # ...
184
+ end
185
+ ```
186
+
187
+ ## Event subscribers
188
+
189
+ Events subscribers offer a way to define event subscriptions from a custom
190
+ class.
191
+
192
+ In its simplest form, you can match an event to a method in the class.
193
+
194
+ ```ruby
195
+ class OrderCreationEmailSubscriber
196
+ include Omnes::Subscriber
197
+
198
+ handle :order_created, with: :send_confirmation_email
199
+
200
+ attr_reader :service
201
+
202
+ def initialize(service: OrderCreationEmail.new)
203
+ @service = service
204
+ end
205
+
206
+ def send_confirmation_email(event)
207
+ service.send(number: event.number, email: event.user_email)
208
+ end
209
+ end
210
+
211
+ OrderCreationEmailSubscriber.new.subscribe_to(bus)
212
+ ```
213
+
214
+ Equivalent to the subscribe methods we've seen above, you can also subscribe to
215
+ all events:
216
+
217
+ ```ruby
218
+ class LogEventsSubscriber
219
+ include Omnes::Subscriber
220
+
221
+ handle_all with: :log_event
222
+
223
+ attr_reader :logger
224
+
225
+ def initialize(logger: Logger.new(STDOUT))
226
+ @logger = logger
227
+ end
228
+
229
+ def log_event(event)
230
+ logger.info("Event #{event.name} published")
231
+ end
232
+ end
233
+ ```
234
+
235
+ You can also handle the event with your own custom matcher:
236
+
237
+ ```ruby
238
+ class OrderSubscriber
239
+ include Omnes::Subscriber
240
+
241
+ handle_with_matcher ORDER_EVENTS_MATCHER, with: :register_order_event
242
+
243
+ def register_order_event(event)
244
+ # ...
245
+ end
246
+ end
247
+ ```
248
+
249
+ ### Autodiscovering event handlers
250
+
251
+ You can let the event handlers to be automatically discovered.You need to
252
+ enable the `autodiscover` feature and prefix the event name with `on_` for your
253
+ handler name.
254
+
255
+ ```ruby
256
+ class OrderCreationEmailSubscriber
257
+ include Omnes::Subscriber[
258
+ autodiscover: true
259
+ ]
260
+
261
+ # ...
262
+
263
+ def on_order_created(event)
264
+ # ...
265
+ end
266
+ end
267
+ ```
268
+
269
+ If you prefer, you can make `autodiscover` on by default:
270
+
271
+ ```ruby
272
+ Omnes.config.subscriber.autodiscover = true
273
+ ```
274
+
275
+ You can also specify your own autodiscover strategy. It must be something
276
+ callable, transforming the event name into the handler name.
277
+
278
+ ```ruby
279
+ AUTODISCOVER_STRATEGY = ->(event_name) { event_name }
280
+
281
+ class OrderCreationEmailSubscriber
282
+ include Omnes::Subscriber[
283
+ autodiscover: true,
284
+ autodiscover_strategy: AUTODISCOVER_STRATEGY
285
+ ]
286
+
287
+ # ...
288
+
289
+ def order_created(event)
290
+ # ...
291
+ end
292
+ end
293
+ ```
294
+
295
+ ### Adapters
296
+
297
+ Subscribers are not limited to use a method as event handler. They can interact
298
+ with the whole instance context and leverage it to build adapters.
299
+
300
+ Omnes ships with a few of them.
301
+
302
+ #### Sidekiq adapter
303
+
304
+ The Sidekiq adapter allows creating a subscription to be processed as a
305
+ [Sidekiq](https://sidekiq.org) background job.
306
+
307
+ Sidekiq requires that the argument passed to `#perform` is serializable. By
308
+ default, the result of calling `#payload` in the event is taken.
309
+
310
+ ```ruby
311
+ class OrderCreationEmailSubscriber
312
+ include Omnes::Subscriber
313
+ include Sidekiq::Job
314
+
315
+ handle :order_created, with: Adapter::Sidekiq
316
+
317
+ def perform(payload)
318
+ OrderCreationEmail.send(number: payload["number"], email: payload["user_email"])
319
+ end
320
+ end
321
+
322
+ bus = Omnes::Bus.new
323
+ bus.register(:order_created)
324
+ OrderCreationEmailSubscriber.new.subscribe_to(bus)
325
+ bus.publish(:order_created, "number" => order.number, "user_email" => user.email)
326
+ ```
327
+
328
+ However, you can configure how the event is serialized thanks to the
329
+ `serializer:` option. It needs to be something callable taking the event as
330
+ argument:
331
+
332
+ ```ruby
333
+ handle :order_created, with: Adapter::Sidekiq[serializer: :serialized_payload.to_proc]
334
+ ```
335
+
336
+ You can also globally configure the default serializer:
337
+
338
+ ```ruby
339
+ Omnes.config.subscriber.adapter.sidekiq.serializer = :serialized_payload.to_proc
340
+ ```
341
+
342
+ You can delay the callback execution from the publication time with the `.in`
343
+ method (analogous to `Sidekiq::Job.perform_in`):
344
+
345
+ ```ruby
346
+ handle :order_created, with: Adapter::Sidekiq.in(60)
347
+ ```
348
+
349
+ #### ActiveJob adapter
350
+
351
+ The ActiveJob adapter allows creating a subscription to be processed as an
352
+ [ActiveJob](https://edgeguides.rubyonrails.org/active_job_basics.html)
353
+ background job.
354
+
355
+ ActiveJob requires that the argument passed to `#perform` is serializable. By
356
+ default, the result of calling `#payload` in the event is taken.
357
+
358
+ ```ruby
359
+ class OrderCreationEmailSubscriber < ActiveJob
360
+ include Omnes::Subscriber
361
+
362
+ handle :order_created, with: Adapter::ActiveJob
363
+
364
+ def perform(payload)
365
+ OrderCreationEmail.send(number: payload["number"], email: payload["user_email"])
366
+ end
367
+ end
368
+
369
+ bus = Omnes::Bus.new
370
+ bus.register(:order_created)
371
+ OrderCreationEmailSubscriber.new.subscribe_to(bus)
372
+ bus.publish(:order_created, "number" => order.number, "user_email" => user.email)
373
+ ```
374
+
375
+ However, you can configure how the event is serialized thanks to the
376
+ `serializer:` option. It needs to be something callable taking the event as
377
+ argument:
378
+
379
+ ```ruby
380
+ handle :order_created, with: Adapter::ActiveJob[serializer: :serialized_payload.to_proc]
381
+ ```
382
+
383
+ You can also globally configure the default serializer:
384
+
385
+ ```ruby
386
+ Omnes.config.subscriber.adapter.active_job.serializer = :serialized_payload.to_proc
387
+ ```
388
+
389
+ #### Custom adapters
390
+
391
+ Custom adapters can be built. They need to implement a method `#call` taking
392
+ the instance of `Omnes::Subscriber` and the event.
393
+
394
+ Here's a custom adapter executing a subscriber method in a different
395
+ thread (we add an extra argument for the method name, and we partially apply it
396
+ at the definition time to obey the adapter requirements).
397
+
398
+ ```ruby
399
+ THREAD_ADAPTER = lambda do |method_name, instance, event|
400
+ Thread.new { instance.method(method_name).call(event) }
401
+ end
402
+
403
+ class OrderCreationEmailSubscriber
404
+ include Omnes::Subscriber
405
+ include Sidekiq::Job
406
+
407
+ handle :order_created, with: THREAD_ADAPTER.curry[:order_created]
408
+
409
+ def order_created(event)
410
+ # ...
411
+ end
412
+ end
413
+ ```
414
+
415
+ Alternatively, adapters can be curried and only take the instance as an
416
+ argument, returning a one-argument callable taking the event. For instance, we
417
+ could also have defined the thread adapter like this:
418
+
419
+ ```ruby
420
+ class ThreadAdapter
421
+ attr_reader :method_name
422
+
423
+ def initialize(method_name)
424
+ @method_name = method_name
425
+ end
426
+
427
+ def call(instance)
428
+ raise unless instance.respond_to?(method_name)
429
+
430
+ ->(event) { instance.method(:call).(event) }
431
+ end
432
+ end
433
+
434
+ # ...
435
+ handle :order_created, with: ThreadAdapter.new(:order_created)
436
+ # ...
437
+ ```
438
+
439
+ ## Debugging
440
+
441
+ ### Unsubscribing
442
+
443
+ When you create a subscription, an instance of
444
+ [`Omnes::Subscription`](lib/omnes/subscription.rb) is returned (or an array for
445
+ `Omnes::Subscriber`). It can be used to unsubscribe it in case you need to
446
+ debug some behavior.
447
+
448
+ ```ruby
449
+ subscription = bus.subscribe(:order_created, OrderCreationEmailSubscription.new)
450
+ bus.unsubscribe(subscription)
451
+ ```
452
+
453
+ ### Registration
454
+
455
+ Whenever you register an event, you get back an [`Omnes::Registry::Registration`](lib/omnes/registry.rb)
456
+ instance. It gives access to both the registered `#event_name` and the
457
+ `#caller_location` of the registration.
458
+
459
+ An `Omnes::Bus` contains a reference to its registry, which can be used to
460
+ retrieve a registration later on.
461
+
462
+ ```ruby
463
+ bus.registry.registration(:order_created)
464
+ ```
465
+
466
+ You can also use the registry to retrieve all registered event names:
467
+
468
+ ```ruby
469
+ bus.registry.event_names
470
+ ```
471
+
472
+ See [`Omnes::Registry`](lib/omnes/registry.rb) for other available methods.
473
+
474
+ ### Publication
475
+
476
+ When you publish an event, you get back an
477
+ [`Omnes::Publication`](lib/omnes/publication.rb) instance. It contains some
478
+ attributes that allow observing what happened:
479
+
480
+ - `#event` contains the event instance that has been published.
481
+ - `#caller_location` refers to the publication caller.
482
+ - `#time` is the time stamp for the publication.
483
+ - `#executions` contains an array of
484
+ `Omnes::Execution`(lib/omnes/execution.rb). Read more below.
485
+
486
+ `Omnes::Execution` represents a subscription individual execution. It contains
487
+ the following attributes:
488
+
489
+ - `#subscription` is an instance of [`Omnes::Subscription`](lib/omnes/subscripiton.rb).
490
+ - `#result` contains the result of the execution.
491
+ - `#benchmark` of the operation.
492
+ - `#time` is the time where the execution started.
493
+
494
+ ## Testing
495
+
496
+ Ideally, you wouldn't need big setups to test your event-driven behavior. You
497
+ could design your subscribers to use lightweight mocks for any external or
498
+ operation at the integration level. Example:
499
+
500
+ ```ruby
501
+ if # test environment
502
+ bus.subscribe(:order_created, OrderCreationEmailSubscriber.new(service: MockService.new)
503
+ else
504
+ bus.subscribe(:order_created, OrderCreationEmailSubscriber.new)
505
+ end
506
+ ```
507
+
508
+ Then, at the unit level, you can test your subscribers as any other class.
509
+
510
+ However, there's also a handy `Omnes::Bus#performing_only` method that allows
511
+ running a code block with only a selection of subscriptions as potential
512
+ callbacks for published events.
513
+
514
+ ```ruby
515
+ creation_subscription = bus.subscribe(:order_created, OrderCreationEmailSubscriber.new)
516
+ deletion_subscription = bus.subscribe(:order_deleted, OrderDeletionSubscriber.new)
517
+ bus.performing_only(creation_subscription) do
518
+ bus.publish(:order_created, number: order.number, user_email: user.email) # `creation_subscription` will run
519
+ bus.publish(:order_deleted, number: order.number) # `deletion_subscription` won't run
520
+ end
521
+ bus.publish(:order_deleted, number: order.number) # `deletion_subscription` will run
522
+ ```
523
+
524
+ Remember that the array of created subscriptions is returned on `Omnes::Subscriber#subscribe_to`.
525
+
526
+ There's also a specialized `Omnes::Bus#performing_nothing` method that runs no
527
+ subscriptions for the duration of the block.
528
+
529
+ ## Recipes
530
+
531
+ ### Rails
532
+
533
+ Create an initializer in `config/initializers/omnes.rb`:
534
+
535
+ ```ruby
536
+ require "omnes"
537
+
538
+ Omnes.config.subscriber.autodiscover = true
539
+
540
+ Bus = Omnes::Bus.new
541
+ Bus.register(:order_created)
542
+
543
+ OrderCreationEmailSubscriber.new.subscribe_to(Bus)
544
+ ```
545
+
546
+ We can define `OrderCreationEmailSubscriber` in
547
+ `app/subscribers/order_creation_email_subscriber.rb`:
548
+
549
+ ```ruby
550
+ # frozen_string_literal: true
551
+
552
+ class OrderCreationEmailSubscriber
553
+ include Omnes::Subscriber
554
+
555
+ def on_order_created(event)
556
+ # ...
557
+ end
558
+ end
559
+ ```
560
+
561
+ Ideally, you'll publish your event in a [custom service
562
+ layer](https://www.toptal.com/ruby-on-rails/rails-service-objects-tutorial). If
563
+ that's not possible, you can publish it in the controller.
564
+
565
+ We strongly discourage publishing events as part of an `ActiveRecord` callback.
566
+ Subscribers should run code that is independent of the main business
567
+ transaction. As such, they shouldn't run within the same database transaction,
568
+ and they should be decoupled of persistence responsibilities altogether.
569
+
570
+ ## Why is it called Omnes?
571
+
572
+ Why an Event Bus is called an _Event Bus_? It's a long story:
573
+
574
+ - The first leap leaves us with the hardware computer buses. They move data from one hardware component to another.
575
+ - The name leaked to the software to describe architectures that communicate parts by sending messages, like an Event Bus.
576
+ - That was given as an analogy of buses as vehicles, where not data but people are transported.
577
+ - _Bus_ is a clipped version of the Latin _omnibus_. That's what buses used to be called (and they're still called like that in some places, like Argentina).
578
+ - _Bus_ stands for the preposition _for_, while _Omni_ means _all_. That's _for
579
+ all_, but, for some reason, we decided to keep the part void of meaning.
580
+ - Why were they called _omnibus_? Let's move back to 1823 and talk about a man named Stanislas Baudry.
581
+ - Stanislas lived in a suburb of Nantes, France. There, he ran a corn mill.
582
+ - Hot water was a by-product of the mill, so Stanislas decided to build a spa business.
583
+ - As the mill was on the city's outskirts, he arranged some horse-drawn
584
+ transportation to bring people to his spa.
585
+ - It turned out that people weren't interested in it, but they did use the carriage to go to and fro.
586
+ - The first stop of the service was in front of the shop of a hatter called
587
+ __Omnes__.
588
+ - Omnes was a witty man. He'd named his shop with a pun on his Latin-sounding
589
+ name: _Omnes Omnibus_. That means something like _everything for everyone_.
590
+ - Therefore, people in Nantes started to call _Omnibus_ to the new service.
591
+
592
+ So, it turns out we call it the "Event Bus" because presumably, the parents of
593
+ Omnes gave him that name. So, the name of this library, it's a tribute to
594
+ Omnes, the hatter.
595
+
596
+ By the way, in case you're wondering, Stanislas, the guy of the mill, closed
597
+ both it and the spa to run his service.
598
+ Eventually, he moved to Paris to earn more money in a bigger city.
599
+
600
+ ## Development
601
+
602
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run
603
+ `rake spec` to run the tests. You can also run `bin/console` for an interactive
604
+ prompt that will allow you to experiment.
605
+
606
+ ## Contributing
607
+
608
+ Bug reports and pull requests are welcome on GitHub at https://github.com/nebulab/omnes. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/nebulab/omnes/blob/master/CODE_OF_CONDUCT.md).
609
+
610
+
611
+ ## License
612
+
613
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
614
+
615
+ ## Code of Conduct
616
+
617
+ Everyone interacting in the Omnes project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/nebulab/omnes/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/bin/console ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ require "bundler/setup"
6
+ require "omnes"
7
+
8
+ # You can add fixtures and/or initialization code here to make experimenting
9
+ # with your gem easier. You can also use a different console, if you like.
10
+
11
+ # (If you use this, don't forget to add pry to your Gemfile!)
12
+ # require "pry"
13
+ # Pry.start
14
+
15
+ require "irb"
16
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here