├── .github └── workflows │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── .yardopts ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── omnes.rb └── omnes │ ├── bus.rb │ ├── configurable.rb │ ├── errors.rb │ ├── event.rb │ ├── execution.rb │ ├── publication.rb │ ├── publication_context.rb │ ├── registry.rb │ ├── subscriber.rb │ ├── subscriber │ ├── adapter.rb │ ├── adapter │ │ ├── active_job.rb │ │ ├── method.rb │ │ ├── method │ │ │ └── errors.rb │ │ └── sidekiq.rb │ ├── errors.rb │ └── state.rb │ ├── subscription.rb │ ├── unstructured_event.rb │ └── version.rb ├── omnes.gemspec └── spec ├── spec_helper.rb ├── support └── shared_examples │ └── bus.rb └── unit ├── omnes ├── bus_spec.rb ├── configurable_spec.rb ├── event_spec.rb ├── publication_context_spec.rb ├── registry_spec.rb ├── subscriber │ ├── adapter │ │ ├── active_job_spec.rb │ │ ├── method_spec.rb │ │ └── sidekiq_spec.rb │ └── adapter_spec.rb ├── subscriber_spec.rb ├── subscription_spec.rb └── unstructured_event_spec.rb └── omnes_spec.rb /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v2 18 | - name: Set up Ruby 19 | uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: 3.1 22 | bundler-cache: true 23 | - name: Rubocop 24 | run: bundle exec rubocop 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | ruby-version: ['3.0', '3.1', '3.2'] 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v2 20 | - name: Set up Ruby 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: ${{ matrix.ruby-version }} 24 | bundler-cache: true 25 | - name: Run tests 26 | run: bundle exec rake 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | Gemfile.lock 10 | 11 | # rspec failure tracking 12 | .rspec_status 13 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 3.0 3 | NewCops: enable 4 | SuggestExtensions: false 5 | 6 | Metrics/BlockLength: 7 | Exclude: 8 | - 'spec/**/*' 9 | - omnes.gemspec 10 | 11 | Style/AccessModifierDeclarations: 12 | Exclude: 13 | - 'spec/**/*' 14 | 15 | Style/BlockDelimiters: 16 | Exclude: 17 | - 'spec/**/*' 18 | 19 | Style/MultilineBlockChain: 20 | Enabled: false 21 | 22 | Style/LambdaCall: 23 | EnforcedStyle: braces 24 | 25 | Style/StringLiterals: 26 | EnforcedStyle: double_quotes 27 | 28 | Layout/LineLength: 29 | Max: 120 30 | 31 | Naming/MethodName: 32 | AllowedPatterns: 33 | - '.*Type' 34 | 35 | Lint/ConstantDefinitionInBlock: 36 | Exclude: 37 | - 'spec/**/*' 38 | 39 | Metrics/MethodLength: 40 | Max: 15 41 | 42 | Style/MutableConstant: 43 | Exclude: 44 | - 'spec/**/*' 45 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: ruby 3 | cache: bundler 4 | rvm: 5 | - 2.7.0 6 | before_install: gem install bundler -v 2.1.4 7 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --title 'omnes' 2 | --query '@api.text != "private"' 3 | --embed-mixins 4 | --output doc 5 | --readme README.md 6 | --files CHANGELOG.md 7 | --markup markdown 8 | --markup-provider=redcarpet 9 | lib/**/*.rb 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.2.2] - 2022-05-03 10 | 11 | ### Added 12 | - Support Ruby 2.5 & 2.6 [#6](https://github.com/nebulab/omnes/pull/6). 13 | 14 | ## [0.2.1] - 2022-04-19 15 | 16 | ### Added 17 | - Added `Omnes::Bus#clear` for autoloading [#4](https://github.com/nebulab/omnes/pull/4). 18 | 19 | ### Changed 20 | - Fix re-adding autodiscovered subscriptions on subsequent calls [#5](https://github.com/nebulab/omnes/pull/5). 21 | 22 | ## [0.2.0] - 2022-04-15 23 | 24 | ### Added 25 | - Be able to fetch subscriptions by id from the bus [#1](https://github.com/nebulab/omnes/pull/1). 26 | - Use ad-hoc configuration system (and make Omnes zero-deps) [#2](https://github.com/nebulab/omnes/pull/2). 27 | - Bind a publication context to subscriptions [#3](https://github.com/nebulab/omnes/pull/3). 28 | 29 | ## [0.1.0] - 2022-03-23 30 | 31 | [Unreleased]: https://github.com/nebulab/omnes/compare/v0.2.2...HEAD 32 | [0.2.1]: https://github.com/nebulab/omnes/compare/v0.2.1...v0.2.2 33 | [0.2.1]: https://github.com/nebulab/omnes/compare/v0.2.0...v0.2.1 34 | [0.2.0]: https://github.com/nebulab/omnes/compare/v0.1.0...v0.2.0 35 | [0.1.0]: https://github.com/nebulab/omnes/releases/tag/v0.1.0 36 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at marc@lamarciana.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in omnes.gemspec 6 | gemspec 7 | 8 | gem "activejob" 9 | gem "rake", "~> 12.0" 10 | gem "redcarpet", "~> 3.5" 11 | gem "rspec", "~> 3.0" 12 | gem "rubocop", "~> 1.25", require: false 13 | gem "sidekiq", "~> 6.4" 14 | gem "yard", "~> 0.9" 15 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Marc Busqué 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 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 `:omnes_event_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. 77 | 78 | ```ruby 79 | class OrderCreatedEvent 80 | include Omnes::Event 81 | 82 | attr_reader :number, :user_email 83 | 84 | def initialize(number:, user_email:) 85 | @number = number 86 | @user_email = user_email 87 | end 88 | end 89 | 90 | event = OrderCreatedEvent.new(number: order.number, user_email: user.email) 91 | bus.publish(event) 92 | ``` 93 | 94 | By default, an event name instance equals the event class name downcased, 95 | underscored and with the `Event` suffix removed if present (`:order_created` in 96 | the previous example). However, you can configure your own name generator based 97 | on the event instance: 98 | 99 | ```ruby 100 | event_name_as_class = ->(event) { event.class.name.to_sym } # :OrderCreatedEvent in the example 101 | Omnes.config.event.name_builder = event_name_as_class 102 | ``` 103 | 104 | Instance-backed events provide a well-defined structure, and other features, 105 | like event persistence, can be added on top of them. 106 | 107 | ## Subscribing to events 108 | 109 | You can subscribe to a specific event to run some code whenever it's published. 110 | The event is yielded to the subscription block: 111 | 112 | ```ruby 113 | bus.subscribe(:order_created) do |event| 114 | # ... 115 | end 116 | ``` 117 | 118 | For unstructured events, the published data is made available through the 119 | `payload` method, although `#[]` can be used as a shortcut: 120 | 121 | ```ruby 122 | bus.subscribe(:order_created) do |event| 123 | OrderCreationEmail.new.send(number: event[:number], email: event[:user_email]) 124 | # OrderCreationEmail.new.send(number: event.payload[:number], email: event.payload[:user_email]) 125 | end 126 | ``` 127 | 128 | Otherwise, use the event instance according to its structure: 129 | 130 | ```ruby 131 | bus.subscribe(:order_created) do |event| 132 | OrderCreationEmail.new.send(number: event.number, email: event.user_email) 133 | end 134 | ``` 135 | 136 | The subscription code can also be given as anything responding to a method 137 | `#call`. 138 | 139 | ```ruby 140 | class OrderCreationEmailSubscription 141 | def call(event) 142 | OrderCreationEmail.new.send(number: event.number, email: event.user_email) 143 | end 144 | end 145 | 146 | bus.subscribe(:order_created, OrderCreationEmailSubscription.new) 147 | ``` 148 | 149 | However, see [Event subscribers](#event-subscribers) section bellow for a more powerful way 150 | to define standalone event handlers. 151 | 152 | ### Global subscriptions 153 | 154 | You can also create a subscription that will run for all events: 155 | 156 | ```ruby 157 | class LogEventsSubscription 158 | attr_reader :logger 159 | 160 | def initialize(logger: Logger.new(STDOUT)) 161 | @logger = logger 162 | end 163 | 164 | def call(event) 165 | logger.info("Event #{event.omnes_event_name} published") 166 | end 167 | end 168 | 169 | bus.subscribe_to_all(LogEventsSubscription.new) 170 | ``` 171 | 172 | ### Custom matcher subscriptions 173 | 174 | Custom event matchers can be defined. A matcher is something responding to 175 | `#call` and taking the event as an argument. It must return `true` or `false` 176 | to match or ignore the event. 177 | 178 | ```ruby 179 | ORDER_EVENTS_MATCHER = ->(event) { event.omnes_event_name.start_with?(:order) } 180 | 181 | bus.subscribe_with_matcher(ORDER_EVENTS_MATCHER) do |event| 182 | # ... 183 | end 184 | ``` 185 | 186 | ### Referencing subscriptions 187 | 188 | For all subscription methods we've seen, an `Omnes::Subscription` instance is 189 | returned. Holding that reference can be useful for [debugging](#debugging) and 190 | [testing](#testing) purposes. 191 | 192 | Often though, you won't have the reference at hand when you need it. 193 | Thankfully, you can provide a subscription identifier on subscription time and 194 | use it later to fetch the subscription instance from the bus. A subscription 195 | identifier needs to be a `Symbol`: 196 | 197 | ```ruby 198 | bus.subscribe(:order_created, OrderCreationEmailSubscription.new, id: :order_created_email) 199 | subscription = bus.subscription(:order_created_email) 200 | ``` 201 | 202 | ## Event subscribers 203 | 204 | Events subscribers offer a way to define event subscriptions from a custom 205 | class. 206 | 207 | In its simplest form, you can match an event to a method in the class. 208 | 209 | ```ruby 210 | class OrderCreationEmailSubscriber 211 | include Omnes::Subscriber 212 | 213 | handle :order_created, with: :send_confirmation_email 214 | 215 | attr_reader :service 216 | 217 | def initialize(service: OrderCreationEmail.new) 218 | @service = service 219 | end 220 | 221 | def send_confirmation_email(event) 222 | service.send(number: event.number, email: event.user_email) 223 | end 224 | end 225 | ``` 226 | 227 | You add the subscriptions by calling the `#subscribe_to` method on an instance: 228 | 229 | ```ruby 230 | OrderCreationEmailSubscriber.new.subscribe_to(bus) 231 | ``` 232 | 233 | Equivalent to the subscribe methods we've seen above, you can also subscribe to 234 | all events: 235 | 236 | ```ruby 237 | class LogEventsSubscriber 238 | include Omnes::Subscriber 239 | 240 | handle_all with: :log_event 241 | 242 | attr_reader :logger 243 | 244 | def initialize(logger: Logger.new(STDOUT)) 245 | @logger = logger 246 | end 247 | 248 | def log_event(event) 249 | logger.info("Event #{event.omnes_event_name} published") 250 | end 251 | end 252 | ``` 253 | 254 | You can also handle the event with your own custom matcher: 255 | 256 | ```ruby 257 | class OrderSubscriber 258 | include Omnes::Subscriber 259 | 260 | handle_with_matcher ORDER_EVENTS_MATCHER, with: :register_order_event 261 | 262 | def register_order_event(event) 263 | # ... 264 | end 265 | end 266 | ``` 267 | 268 | Likewise, you can provide [identifiers to reference 269 | subscriptions](#referencing-subscriptions): 270 | 271 | ```ruby 272 | handle :order_created, with: :send_confirmation_email, id: :order_creation_email_subscriber 273 | ``` 274 | 275 | As you can subscribe multiple instances of a subscriber to the same bus, you 276 | might need to create a different identifier for each of them. For those cases, 277 | you can pass a lambda taking the subscriber instance: 278 | 279 | ```ruby 280 | handle :order_created, with: :send_confirmation_email, id: ->(subscriber) { :"#{subscriber.id}_order_creation_email_subscriber" } 281 | ``` 282 | 283 | ### Autodiscovering event handlers 284 | 285 | You can let the event handlers to be automatically discovered.You need to 286 | enable the `autodiscover` feature and prefix the event name with `on_` for your 287 | handler name. 288 | 289 | ```ruby 290 | class OrderCreationEmailSubscriber 291 | include Omnes::Subscriber[ 292 | autodiscover: true 293 | ] 294 | 295 | # ... 296 | 297 | def on_order_created(event) 298 | # ... 299 | end 300 | end 301 | ``` 302 | 303 | If you prefer, you can make `autodiscover` on by default: 304 | 305 | ```ruby 306 | Omnes.config.subscriber.autodiscover = true 307 | ``` 308 | 309 | You can also specify your own autodiscover strategy. It must be something 310 | callable, transforming the event name into the handler name. 311 | 312 | ```ruby 313 | AUTODISCOVER_STRATEGY = ->(event_name) { event_name } 314 | 315 | class OrderCreationEmailSubscriber 316 | include Omnes::Subscriber[ 317 | autodiscover: true, 318 | autodiscover_strategy: AUTODISCOVER_STRATEGY 319 | ] 320 | 321 | # ... 322 | 323 | def order_created(event) 324 | # ... 325 | end 326 | end 327 | ``` 328 | 329 | The strategy can also be globally set: 330 | 331 | ```ruby 332 | Omnes.config.subscriber.autodiscover_strategy = AUTODISCOVER_STRATEGY 333 | ``` 334 | 335 | ### Adapters 336 | 337 | Subscribers are not limited to use a method as event handler. They can interact 338 | with the whole instance context and leverage it to build adapters. 339 | 340 | Omnes ships with a few of them. 341 | 342 | #### Sidekiq adapter 343 | 344 | The Sidekiq adapter allows creating a subscription to be processed as a 345 | [Sidekiq](https://sidekiq.org) background job. 346 | 347 | Sidekiq requires that the argument passed to `#perform` is serializable. By 348 | default, the result of calling `#payload` in the event is taken. 349 | 350 | ```ruby 351 | class OrderCreationEmailSubscriber 352 | include Omnes::Subscriber 353 | include Sidekiq::Job 354 | 355 | handle :order_created, with: Adapter::Sidekiq 356 | 357 | def perform(payload) 358 | OrderCreationEmail.send(number: payload["number"], email: payload["user_email"]) 359 | end 360 | end 361 | 362 | bus = Omnes::Bus.new 363 | bus.register(:order_created) 364 | OrderCreationEmailSubscriber.new.subscribe_to(bus) 365 | bus.publish(:order_created, "number" => order.number, "user_email" => user.email) 366 | ``` 367 | 368 | However, you can configure how the event is serialized thanks to the 369 | `serializer:` option. It needs to be something callable taking the event as 370 | argument: 371 | 372 | ```ruby 373 | handle :order_created, with: Adapter::Sidekiq[serializer: :serialized_payload.to_proc] 374 | ``` 375 | 376 | You can also globally configure the default serializer: 377 | 378 | ```ruby 379 | Omnes.config.subscriber.adapter.sidekiq.serializer = :serialized_payload.to_proc 380 | ``` 381 | 382 | You can delay the callback execution from the publication time with the `.in` 383 | method (analogous to `Sidekiq::Job.perform_in`): 384 | 385 | ```ruby 386 | handle :order_created, with: Adapter::Sidekiq.in(60) 387 | ``` 388 | 389 | #### ActiveJob adapter 390 | 391 | The ActiveJob adapter allows creating a subscription to be processed as an 392 | [ActiveJob](https://edgeguides.rubyonrails.org/active_job_basics.html) 393 | background job. 394 | 395 | ActiveJob requires that the argument passed to `#perform` is serializable. By 396 | default, the result of calling `#payload` in the event is taken. 397 | 398 | ```ruby 399 | class OrderCreationEmailSubscriber < ActiveJob 400 | include Omnes::Subscriber 401 | 402 | handle :order_created, with: Adapter::ActiveJob 403 | 404 | def perform(payload) 405 | OrderCreationEmail.send(number: payload["number"], email: payload["user_email"]) 406 | end 407 | end 408 | 409 | bus = Omnes::Bus.new 410 | bus.register(:order_created) 411 | OrderCreationEmailSubscriber.new.subscribe_to(bus) 412 | bus.publish(:order_created, "number" => order.number, "user_email" => user.email) 413 | ``` 414 | 415 | However, you can configure how the event is serialized thanks to the 416 | `serializer:` option. It needs to be something callable taking the event as 417 | argument: 418 | 419 | ```ruby 420 | handle :order_created, with: Adapter::ActiveJob[serializer: :serialized_payload.to_proc] 421 | ``` 422 | 423 | You can also globally configure the default serializer: 424 | 425 | ```ruby 426 | Omnes.config.subscriber.adapter.active_job.serializer = :serialized_payload.to_proc 427 | ``` 428 | 429 | #### Custom adapters 430 | 431 | Custom adapters can be built. They need to implement a method `#call` taking 432 | the instance of `Omnes::Subscriber`, the event and, optionally, the publication 433 | context (see [debugging subscriptions](#subscription)). 434 | 435 | Here's a custom adapter executing a subscriber method in a different 436 | thread (we add an extra argument for the method name, and we partially apply it 437 | at the definition time to obey the adapter requirements). 438 | 439 | ```ruby 440 | THREAD_ADAPTER = lambda do |method_name, instance, event| 441 | Thread.new { instance.method(method_name).call(event) } 442 | end 443 | 444 | class OrderCreationEmailSubscriber 445 | include Omnes::Subscriber 446 | 447 | handle :order_created, with: THREAD_ADAPTER.curry[:order_created] 448 | 449 | def order_created(event) 450 | # ... 451 | end 452 | end 453 | ``` 454 | 455 | Alternatively, adapters can be curried and only take the instance as an 456 | argument, returning a callable taking the event. For instance, we could also 457 | have defined the thread adapter like this: 458 | 459 | ```ruby 460 | class ThreadAdapter 461 | attr_reader :method_name 462 | 463 | def initialize(method_name) 464 | @method_name = method_name 465 | end 466 | 467 | def call(instance) 468 | raise unless instance.respond_to?(method_name) 469 | 470 | ->(event) { instance.method(:call).(event) } 471 | end 472 | end 473 | 474 | # ... 475 | handle :order_created, with: ThreadAdapter.new(:order_created) 476 | # ... 477 | ``` 478 | 479 | ## Unsubscribing & clearing 480 | 481 | You can unsubscribe a given subscription by passing its 482 | [reference](#referencing-subscriptions) to `Omnes::Bus#unsubscribe` (see how to 483 | [reference subscriptions](#referencing-subscriptions)): 484 | 485 | ```ruby 486 | subscription = bus.subscribe(:order_created, OrderCreationEmailSubscription.new) 487 | bus.unsubscribe(subscription) 488 | ``` 489 | 490 | Sometimes you might need to leave your bus in a pristine state, with no events 491 | registered or active subscriptions. That can be useful for autoloading in 492 | development: 493 | 494 | ```ruby 495 | bus.clear 496 | bus.registry.event_names # => [] 497 | bus.subscriptions # => [] 498 | ``` 499 | 500 | ## Debugging 501 | 502 | ### Registration 503 | 504 | Whenever you register an event, you get back an [`Omnes::Registry::Registration`](lib/omnes/registry.rb) 505 | instance. It gives access to both the registered `#event_name` and the 506 | `#caller_location` of the registration. 507 | 508 | An `Omnes::Bus` contains a reference to its registry, which can be used to 509 | retrieve a registration later on. 510 | 511 | ```ruby 512 | bus.registry.registration(:order_created) 513 | ``` 514 | 515 | You can also use the registry to retrieve all registered event names: 516 | 517 | ```ruby 518 | bus.registry.event_names 519 | ``` 520 | 521 | See [`Omnes::Registry`](lib/omnes/registry.rb) for other available methods. 522 | 523 | ### Publication 524 | 525 | When you publish an event, you get back an 526 | [`Omnes::Publication`](lib/omnes/publication.rb) instance. It contains some 527 | attributes that allow observing what happened: 528 | 529 | - `#event` contains the event instance that has been published. 530 | - `#executions` contains an array of 531 | `Omnes::Execution`(lib/omnes/execution.rb). Read more below. 532 | - `#context` is an instance of 533 | [`Omnes::PublicationContext`](lib/omnes/publication_context.rb). 534 | 535 | `Omnes::Execution` represents a subscription individual execution. It contains 536 | the following attributes: 537 | 538 | - `#subscription` is an instance of [`Omnes::Subscription`](lib/omnes/subscripiton.rb). 539 | - `#result` contains the result of the execution. 540 | - `#benchmark` of the operation. 541 | - `#time` is the time where the execution started. 542 | 543 | `Omnes::PublicationContext` represents the shared context for all triggered 544 | executions. See [Subscription][#subscription] for details. 545 | 546 | ### Subscription 547 | 548 | If your subscription block or callable object takes a second argument, it'll 549 | contain an instance of an 550 | [`Omnes::PublicationContext`](lib/omnes/publication_context.rb). It allows you 551 | to inspect what triggered a given execution from within that execution code. It 552 | contains: 553 | 554 | - `#caller_location` refers to the publication caller. 555 | - `#time` is the time stamp for the publication. 556 | 557 | ```ruby 558 | class OrderCreationEmailSubscriber 559 | include Omnes::Subscriber 560 | 561 | handle :order_created, with: :send_confirmation_email 562 | 563 | def send_confirmation_email(event, publication_context) 564 | # debugging 565 | abort(publication_context.caller_location.inspect) 566 | 567 | OrderCreationEmail.send(number: event.number, email: event.user_email) 568 | end 569 | end 570 | ``` 571 | 572 | In case you're developing your own async adapter, you can call `#serialized` on 573 | an instance of `Omnes::PublicationContext` to get a serialized version of it. 574 | It'll return a `Hash` with `"caller_location"` and `"time"` keys, and the 575 | respective `String` representations as values. 576 | 577 | ## Testing 578 | 579 | Ideally, you wouldn't need big setups to test your event-driven behavior. You 580 | could design your subscribers to use lightweight mocks for any external or 581 | operation at the integration level. Example: 582 | 583 | ```ruby 584 | if # test environment 585 | bus.subscribe(:order_created, OrderCreationEmailSubscriber.new(service: MockService.new) 586 | else 587 | bus.subscribe(:order_created, OrderCreationEmailSubscriber.new) 588 | end 589 | ``` 590 | 591 | Then, at the unit level, you can test your subscribers as any other class. 592 | 593 | However, there's also a handy `Omnes::Bus#performing_only` method that allows 594 | running a code block with only a selection of subscriptions as potential 595 | callbacks for published events. 596 | 597 | ```ruby 598 | creation_subscription = bus.subscribe(:order_created, OrderCreationEmailSubscriber.new) 599 | deletion_subscription = bus.subscribe(:order_deleted, OrderDeletionSubscriber.new) 600 | bus.performing_only(creation_subscription) do 601 | bus.publish(:order_created, number: order.number, user_email: user.email) # `creation_subscription` will run 602 | bus.publish(:order_deleted, number: order.number) # `deletion_subscription` won't run 603 | end 604 | bus.publish(:order_deleted, number: order.number) # `deletion_subscription` will run 605 | ``` 606 | 607 | Remember that you can get previous [subscription 608 | references](#referencing-subscriptions) thanks to 609 | subscription identifiers. 610 | 611 | There's also a specialized `Omnes::Bus#performing_nothing` method that runs no 612 | subscriptions for the duration of the block. 613 | 614 | ## Configuration 615 | 616 | We've seen the relevant configurable settings in the corresponding sections. 617 | You can also access the configuration in the habitual block syntax: 618 | 619 | ```ruby 620 | Omnes.configure do |config| 621 | config.subscriber.adapter.sidekiq.serializer = :serialized_payload.to_proc 622 | end 623 | ``` 624 | 625 | Finally, nested settings can also be set directly from the affected class. E.g.: 626 | 627 | ```ruby 628 | Omnes::Subscriber::Adapter::Sidekiq.config.serializer = :serialized_payload.to_proc 629 | ``` 630 | 631 | ## Recipes 632 | 633 | ### Rails 634 | 635 | Create an initializer in `config/initializers/omnes.rb`: 636 | 637 | ```ruby 638 | require "omnes" 639 | 640 | Omnes.config.subscriber.autodiscover = true 641 | 642 | Bus = Omnes::Bus.new 643 | 644 | Rails.application.config.to_prepare do 645 | Bus.clear 646 | 647 | Bus.register(:order_created) 648 | 649 | OrderCreationEmailSubscriber.new.subscribe_to(Bus) 650 | end 651 | ``` 652 | 653 | We can define `OrderCreationEmailSubscriber` in 654 | `app/subscribers/order_creation_email_subscriber.rb`: 655 | 656 | ```ruby 657 | # frozen_string_literal: true 658 | 659 | class OrderCreationEmailSubscriber 660 | include Omnes::Subscriber 661 | 662 | def on_order_created(event) 663 | # ... 664 | end 665 | end 666 | ``` 667 | 668 | Ideally, you'll publish your event in a [custom service 669 | layer](https://www.toptal.com/ruby-on-rails/rails-service-objects-tutorial). If 670 | that's not possible, you can publish it in the controller. 671 | 672 | We strongly discourage publishing events as part of an `ActiveRecord` callback. 673 | Subscribers should run code that is independent of the main business 674 | transaction. As such, they shouldn't run within the same database transaction, 675 | and they should be decoupled of persistence responsibilities altogether. 676 | 677 | ## Why is it called Omnes? 678 | 679 | Why an Event Bus is called an _Event Bus_? It's a long story: 680 | 681 | - The first leap leaves us with the hardware computer buses. They move data from one hardware component to another. 682 | - The name leaked to the software to describe architectures that communicate parts by sending messages, like an Event Bus. 683 | - That was given as an analogy of buses as vehicles, where not data but people are transported. 684 | - _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). 685 | - _Bus_ stands for the preposition _for_, while _Omni_ means _all_. That's _for 686 | all_, but, for some reason, we decided to keep the part void of meaning. 687 | - Why were they called _omnibus_? Let's move back to 1823 and talk about a man named Stanislas Baudry. 688 | - Stanislas lived in a suburb of Nantes, France. There, he ran a corn mill. 689 | - Hot water was a by-product of the mill, so Stanislas decided to build a spa business. 690 | - As the mill was on the city's outskirts, he arranged some horse-drawn 691 | transportation to bring people to his spa. 692 | - It turned out that people weren't interested in it, but they did use the carriage to go to and fro. 693 | - The first stop of the service was in front of the shop of a hatter called 694 | __Omnes__. 695 | - Omnes was a witty man. He'd named his shop with a pun on his Latin-sounding 696 | name: _Omnes Omnibus_. That means something like _everything for everyone_. 697 | - Therefore, people in Nantes started to call _Omnibus_ to the new service. 698 | 699 | So, it turns out we call it the "Event Bus" because presumably, the parents of 700 | Omnes gave him that name. So, the name of this library, it's a tribute to 701 | Omnes, the hatter. 702 | 703 | By the way, in case you're wondering, Stanislas, the guy of the mill, closed 704 | both it and the spa to run his service. 705 | Eventually, he moved to Paris to earn more money in a bigger city. 706 | 707 | ## Development 708 | 709 | After checking out the repo, run `bin/setup` to install dependencies. Then, run 710 | `rake spec` to run the tests. You can also run `bin/console` for an interactive 711 | prompt that will allow you to experiment. 712 | 713 | ## Contributing 714 | 715 | 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). 716 | 717 | 718 | ## License 719 | 720 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 721 | 722 | ## Code of Conduct 723 | 724 | 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). 725 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 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 9 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 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__) 17 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 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 9 | -------------------------------------------------------------------------------- /lib/omnes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "omnes/bus" 4 | require "omnes/configurable" 5 | require "omnes/event" 6 | require "omnes/subscriber" 7 | require "omnes/version" 8 | 9 | # Pub/sub library for Ruby. 10 | # 11 | # There're two ways to make use of the pub/sub features Omnes provides: 12 | # 13 | # - Standalone, through an {Omnes::Bus} instance. 14 | # - Mixing in the behavior in another class by including the {Omnes} module. 15 | # 16 | # Refer to {Omnes::Bus} documentation for the available methods. The only 17 | # difference for the mixing use case is that the methods are directly called in 18 | # the including instance. 19 | # 20 | # ``` 21 | # class MyClass 22 | # include Omnes 23 | # 24 | # def initialize 25 | # register(:foo) 26 | # end 27 | # 28 | # def call 29 | # publish(:foo, bar: :baz) 30 | # end 31 | # end 32 | # ``` 33 | # 34 | # Refer to {Omnes::Subscriber} for how to provide event handlers through methods 35 | # defined in a class. 36 | module Omnes 37 | extend Configurable 38 | 39 | nest_config Subscriber 40 | nest_config Event 41 | 42 | # @api private 43 | def self.included(klass) 44 | klass.define_method(:omnes_bus) { @omnes_bus ||= Bus.new(cal_loc_start: 2) } 45 | Bus.instance_methods(false).each do |method| 46 | klass.define_method(method) do |*args, **kwargs, &block| 47 | omnes_bus.send(method, *args, **kwargs, &block) 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/omnes/bus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "omnes/publication" 4 | require "omnes/publication_context" 5 | require "omnes/registry" 6 | require "omnes/subscription" 7 | require "omnes/unstructured_event" 8 | 9 | module Omnes 10 | # An event bus for the publish/subscribe pattern 11 | # 12 | # An instance of this class acts as an event bus middleware for publishers of 13 | # events and their subscriptions. 14 | # 15 | # ``` 16 | # bus = Omnes::Bus.new 17 | # ``` 18 | # 19 | # Before being able to work with a given event, its name (a {Symbol}) needs to 20 | # be registered: 21 | # 22 | # ``` 23 | # bus.register(:foo) 24 | # ``` 25 | # 26 | # An event can be anything responding to a method `:omes_event_name` which, 27 | # needless to say, must match with a registered name. 28 | # 29 | # Typically, there're two main ways to generate events. 30 | # 31 | # An event can be generated at publication time, where you provide its name 32 | # and a payload to be consumed by its subscribers. 33 | # 34 | # ``` 35 | # bus.publish(:foo, bar: :baz) 36 | # ``` 37 | # 38 | # In that case, an instance of {Omnes::UnstructuredEvent} is generated 39 | # under the hood. 40 | # 41 | # Unstructured events are straightforward to create and use, but they're 42 | # harder to debug as they're defined at publication time. On top of that, 43 | # other features, such as event persistence, can't be reliably built on top of 44 | # them. 45 | # 46 | # You can also publish an instance of a class including {Omnes::Event}. The 47 | # only fancy thing it provides is an OOTB event name generated based on the 48 | # class name. See {Omnes::Event} for details. 49 | # 50 | # ``` 51 | # class Foo 52 | # include Omnes::Event 53 | # 54 | # attr_reader :bar 55 | # 56 | # def initialize 57 | # @bar = :baz 58 | # end 59 | # end 60 | # 61 | # bus.publish(Foo.new) 62 | # ``` 63 | # 64 | # Instance-backed events provide a well-defined structure, and other features, 65 | # like event persistence, can be added on top of them. 66 | # 67 | # Regardless of the type of published event, it's yielded to its subscriptions 68 | # so that they can do their job: 69 | # 70 | # ``` 71 | # bus.subscribe(:foo) do |event| 72 | # # event.payload[:bar] or event[:bar] for unstructured events 73 | # # event.bar for the event instance example 74 | # end 75 | # ``` 76 | # 77 | # The subscription code can be given as a block (previous example) or as 78 | # anything responding to a method `#call`. 79 | # 80 | # ``` 81 | # class MySubscription 82 | # def call(event) 83 | # # ... 84 | # end 85 | # end 86 | # 87 | # bus.subscribe(:foo, MySubscription.new) 88 | # ``` 89 | # 90 | # See also {Omnes::Subscriber} for a more powerful way to define standalone 91 | # event handlers. 92 | # 93 | # You can also create a subscription that will run for all events: 94 | # 95 | # ``` 96 | # bus.subscribe_to_all(MySubscription.new) 97 | # ``` 98 | # 99 | # Custom matchers can be defined. A matcher is something responding to `#call` 100 | # and taking the event as an argument. It needs to return `true` or `false` to 101 | # decide whether the subscription needs to be run for that event. 102 | # 103 | # ``` 104 | # matcher ->(event) { event.name.start_with?(:foo) } 105 | # 106 | # bus.subscribe_with_matcher(matcher, MySubscription.new) 107 | # ``` 108 | # 109 | # For all previous subscription methods, a subscription object is returned. 110 | # You can supply a subscription id to it to be able to fetch it from the bus 111 | # later on: 112 | # 113 | # ``` 114 | # subscription = bus.subscribe(:foo, MySubscription.new, id: :foo_sub) 115 | # bus.subscription(:foo_sub) == subscription #=> true 116 | # ``` 117 | # 118 | # A subscription can be referenced when you want to unsubscribe: 119 | # 120 | # ``` 121 | # bus.unsubscribe(subscription) 122 | # ``` 123 | class Bus 124 | # @api private 125 | def self.EventType(value, **payload) 126 | case value 127 | when Symbol 128 | UnstructuredEvent.new(omnes_event_name: value, payload: payload) 129 | else 130 | value 131 | end 132 | end 133 | 134 | # @api private 135 | attr_reader :cal_loc_start, 136 | :subscriptions 137 | 138 | # @!attribute [r] registry 139 | # @return [Omnes::Bus::Registry] 140 | attr_reader :registry 141 | 142 | def initialize(cal_loc_start: 1, registry: Registry.new, subscriptions: []) 143 | @cal_loc_start = cal_loc_start 144 | @registry = registry 145 | @subscriptions = subscriptions 146 | end 147 | 148 | # Registers an event name 149 | # 150 | # @param event_name [Symbol] 151 | # @param caller_location [Thread::Backtrace::Location] Caller location 152 | # associated to the registration. Useful for debugging (shown in error 153 | # messages). It defaults to this method's caller. 154 | # 155 | # @raise [Omnes::AlreadyRegisteredEventError] when the event is already 156 | # registered 157 | # @raise [Omnes::InvalidEventNameError] when the event is not a {Symbol} 158 | # 159 | # @return [Omnes::Registry::Registration] 160 | def register(event_name, caller_location: caller_locations(cal_loc_start)[0]) 161 | registry.register(event_name, caller_location: caller_location) 162 | end 163 | 164 | # Publishes an event, running all matching subscriptions 165 | # 166 | # @overload publish(event_name, caller_location:, **payload) 167 | # @param event_name [Symbol] Name for the generated 168 | # {Omnes::UnstructuredEvent} event. 169 | # @param **payload [Hash] Payload for the generated 170 | # {Omnes::UnstrUnstructuredEvent} 171 | # 172 | # @overload publish(event, caller_location:) 173 | # @param event [#name] An event instance 174 | # 175 | # @param caller_location [Thread::Backtrace::Location] Caller location 176 | # associated to the publication. Useful for debugging (shown in error 177 | # messages). It defaults to this method's caller. 178 | # 179 | # @return [Omnes::Publication] A publication object encapsulating metadata 180 | # for the event and the originated subscription executions 181 | # 182 | # @raise [Omnes::UnknownEventError] When event name has not been registered 183 | def publish(event, caller_location: caller_locations(cal_loc_start)[0], **payload) 184 | publication_time = Time.now.utc 185 | event = self.class.EventType(event, **payload) 186 | registry.check_event_name(event.omnes_event_name) 187 | publication_context = PublicationContext.new(caller_location: caller_location, time: publication_time) 188 | executions = execute_subscriptions_for_event(event, publication_context) 189 | 190 | Publication.new( 191 | event: event, 192 | executions: executions, 193 | context: publication_context 194 | ) 195 | end 196 | 197 | # Adds a subscription for a single event 198 | # 199 | # @param event_name [Symbol] Name of the event 200 | # @param callable [#call] Subscription callback taking the event 201 | # @param id [Symbol] Unique identifier for the subscription 202 | # @yield [event] Subscription callback if callable is not given 203 | # 204 | # @return [Omnes::Subscription] 205 | # 206 | # @raise [Omnes::UnknownEventError] When event name has not been registered 207 | def subscribe(event_name, callable = nil, id: Subscription.random_id, &block) 208 | registry.check_event_name(event_name) 209 | 210 | subscribe_with_matcher(Subscription::SINGLE_EVENT_MATCHER.curry[event_name], callable, id: id, &block) 211 | end 212 | 213 | # Adds a subscription for all events 214 | # 215 | # @param callable [#call] Subscription callback taking the event 216 | # @param id [Symbol] Unique identifier for the subscription 217 | # @yield [event] Subscription callback if callable is not given 218 | # 219 | # @return [Omnes::Subscription] 220 | def subscribe_to_all(callable = nil, id: Subscription.random_id, &block) 221 | subscribe_with_matcher(Subscription::ALL_EVENTS_MATCHER, callable, id: id, &block) 222 | end 223 | 224 | # Adds a subscription with given matcher 225 | # 226 | # @param matcher [#call] Callable taking the event and returning a boolean 227 | # @param callable [#call] Subscription callback taking the event 228 | # @param id [Symbol] Unique identifier for the subscription 229 | # @yield [event] Subscription callback if callable is not given 230 | # 231 | # @return [Omnes::Subscription] 232 | def subscribe_with_matcher(matcher, callable = nil, id: Subscription.random_id, &block) 233 | raise DuplicateSubscriptionIdError.new(id: id, bus: self) if subscription(id) 234 | 235 | callback = callable || block 236 | Subscription.new(matcher: matcher, callback: callback, id: id).tap do |subscription| 237 | @subscriptions << subscription 238 | end 239 | end 240 | 241 | # Removes a subscription 242 | # 243 | # @param subscription [Omnes::Subscription] 244 | def unsubscribe(subscription) 245 | @subscriptions.delete(subscription) 246 | end 247 | 248 | # Runs given block performing only a selection of subscriptions 249 | # 250 | # That's something useful for testing purposes, as it allows to silence 251 | # subscriptions that are not part of the system under test. 252 | # 253 | # After the block is over, original subscriptions are restored. 254 | # 255 | # @param selection [Array] 256 | # @yield Block to run 257 | # 258 | # @raise [Omnes::UnknownSubscriptionError] when the subscription is not 259 | # known by the bus 260 | def performing_only(*selection) 261 | selection.each do |subscription| 262 | unless subscriptions.include?(subscription) 263 | raise UnknownSubscriptionError.new(subscription: subscription, 264 | bus: self) 265 | end 266 | end 267 | all_subscriptions = subscriptions 268 | @subscriptions = selection 269 | yield 270 | ensure 271 | @subscriptions = all_subscriptions 272 | end 273 | 274 | # Specialized version of {#performing_only} with no subscriptions 275 | # 276 | # @see #performing_only 277 | def performing_nothing(&block) 278 | performing_only(&block) 279 | end 280 | 281 | # Fetch a subscription by its identifier 282 | # 283 | # @param id [Symbol] Subscription identifier 284 | # 285 | # @return [Omnes::Subscription] 286 | def subscription(id) 287 | subscriptions.find { |subscription| subscription.id == id } 288 | end 289 | 290 | # Clears all registered events and subscriptions 291 | # 292 | # Useful for code reloading. 293 | # 294 | # @return [Omnes::Bus] 295 | def clear 296 | tap do 297 | @subscriptions = [] 298 | @registry = Registry.new 299 | end 300 | end 301 | 302 | private 303 | 304 | def execute_subscriptions_for_event(event, publication_context) 305 | subscriptions_for_event(event).map do |subscription| 306 | subscription.(event, publication_context) 307 | end 308 | end 309 | 310 | def subscriptions_for_event(event_name) 311 | @subscriptions.select do |subscription| 312 | subscription.matches?(event_name) 313 | end 314 | end 315 | end 316 | end 317 | -------------------------------------------------------------------------------- /lib/omnes/configurable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Omnes 4 | # Ad-hoc configurable behavior for Omnes 5 | # 6 | # Example: 7 | # 8 | # ``` 9 | # Omnes.configure do |config| 10 | # config.event.name_builder = MY_NAME_BUILDER 11 | # end 12 | # ``` 13 | # 14 | # or 15 | # 16 | # ``` 17 | # Omnes::Event.config.name_builder = MY_NAME_BUILDER 18 | # ``` 19 | module Configurable 20 | # Class where readers and writers are defined 21 | class Config 22 | # @api private 23 | attr_reader :settings 24 | 25 | # @api private 26 | def initialize 27 | @_mutex = Mutex.new 28 | @settings = {} 29 | end 30 | 31 | # @api private 32 | def add_setting(name, default) 33 | @_mutex.synchronize do 34 | @settings[name] = default 35 | define_setting_reader(name) 36 | define_setting_writter(name) 37 | end 38 | end 39 | 40 | # @api private 41 | def add_nesting(constant, name = default_nesting_name(constant)) 42 | @_mutex.synchronize do 43 | define_nesting_reader(constant, name) 44 | end 45 | end 46 | 47 | private 48 | 49 | def define_setting_reader(name) 50 | define_singleton_method(name) { @settings[name] } 51 | end 52 | 53 | def define_setting_writter(name) 54 | define_singleton_method(:"#{name}=") do |value| 55 | @_mutex.synchronize do 56 | @settings[name] = value 57 | end 58 | end 59 | end 60 | 61 | def define_nesting_reader(constant, name) 62 | define_singleton_method(name) { constant.config } 63 | end 64 | end 65 | 66 | # @api private 67 | def self.extended(klass) 68 | klass.instance_variable_set(:@config, Config.new) 69 | end 70 | 71 | # Returns the configuration class 72 | # 73 | # Use this class to access readers and writers for the defined settings or 74 | # nested configurations 75 | # 76 | # @return [Configurable::Config] 77 | def config 78 | @config 79 | end 80 | 81 | # Yields the configuration class 82 | # 83 | # @see #config 84 | def configure 85 | yield @config 86 | end 87 | 88 | # @api private 89 | def setting(name, default:) 90 | config.add_setting(name, default) 91 | end 92 | 93 | # @api private 94 | def nest_config(constant, name: default_nesting_name(constant)) 95 | config.add_nesting(constant, name) 96 | end 97 | 98 | private 99 | 100 | def default_nesting_name(constant) 101 | constant.name 102 | .split("::") 103 | .last 104 | .gsub(/([[:alpha:]])([[:upper:]])/, '\1_\2') 105 | .downcase 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/omnes/errors.rb: -------------------------------------------------------------------------------- 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 if defined?(DidYouMean::PlainFormatter)} 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 | 102 | # Raised when given subscription id is already in use 103 | class DuplicateSubscriptionIdError < Error 104 | attr_reader :id, :bus 105 | 106 | def initialize(id:, bus:) 107 | @id = id 108 | @bus = bus 109 | super(default_message) 110 | end 111 | 112 | private 113 | 114 | def default_message 115 | <<~MSG 116 | #{id} has already been used as a subscription identifier 117 | MSG 118 | end 119 | end 120 | 121 | # Raised when trying to set an invalid subscription identifier 122 | class InvalidSubscriptionNameError < Error 123 | attr_reader :id 124 | 125 | def initialize(id:) 126 | @id = id 127 | super(default_message) 128 | end 129 | 130 | private 131 | 132 | def default_message 133 | <<~MSG 134 | #{id.inspect} is not a valid subscription identifier. Only symbols are 135 | #allowed. 136 | MSG 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/omnes/event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "omnes/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 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 73 | -------------------------------------------------------------------------------- /lib/omnes/execution.rb: -------------------------------------------------------------------------------- 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 43 | -------------------------------------------------------------------------------- /lib/omnes/publication.rb: -------------------------------------------------------------------------------- 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] 20 | attr_reader :executions 21 | 22 | # Publication context, shared by all triggered executions 23 | # 24 | # @return [Omnes::PublicationContext] 25 | attr_reader :context 26 | 27 | # @api private 28 | def initialize(event:, executions:, context:) 29 | @event = event 30 | @executions = executions 31 | @context = context 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/omnes/publication_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Omnes 4 | # Context for an event publication 5 | # 6 | # An instance of this class is shared between all the executions that are 7 | # triggered by the publication of a given event. It's provided to the 8 | # subscriptions as their second argument when they take it. 9 | # 10 | # This class is useful mainly for debugging and logging purposes. 11 | class PublicationContext 12 | # Location for the event publisher 13 | # 14 | # It's set by {Omnes::Bus#publish}, and it points to the caller of that 15 | # method. 16 | # 17 | # @return [Thread::Backtrace::Location] 18 | attr_reader :caller_location 19 | 20 | # Time of the event publication 21 | # 22 | # @return [Time] 23 | attr_reader :time 24 | 25 | # @api private 26 | def initialize(caller_location:, time:) 27 | @caller_location = caller_location 28 | @time = time 29 | end 30 | 31 | # Serialized version of a publication context 32 | # 33 | # @return Hash 34 | def serialized 35 | { 36 | "caller_location" => caller_location.to_s, 37 | "time" => time.to_s 38 | } 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/omnes/registry.rb: -------------------------------------------------------------------------------- 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] 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] 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 103 | -------------------------------------------------------------------------------- /lib/omnes/subscriber.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "omnes/subscriber/adapter" 4 | require "omnes/subscriber/state" 5 | require "omnes/subscription" 6 | 7 | module Omnes 8 | # Supscriptions provider for a {Omnes::Bus} 9 | # 10 | # This module allows an including class to use its context to create event 11 | # handlers. 12 | # 13 | # In its simplest form, you can match an event to a method in the class. 14 | # 15 | # ``` 16 | # class MySubscriber 17 | # include Omnes::Subscriber 18 | # 19 | # handle :foo, with: :my_handler 20 | # 21 | # def my_handler(event) 22 | # # do_something 23 | # end 24 | # end 25 | # ``` 26 | # 27 | # Equivalent to the subscribe methods in {Omnes::Bus}, you can also subscribe 28 | # to all events or use a custom matcher: 29 | # 30 | # ``` 31 | # class MySubscriber 32 | # include Omnes::Subscriber 33 | # 34 | # handle_all with: :my_handler_one 35 | # handle_with_matcher my_matcher, with: :my_handler_two 36 | # 37 | # def my_handler_one(event) 38 | # # do_something 39 | # end 40 | # 41 | # def my_handler_two(event) 42 | # # do_something_else 43 | # end 44 | # end 45 | # ``` 46 | # 47 | # Another option is to let the event handlers be automatically discovered. You 48 | # need to enable the `autodiscover` feature and prefix the event name with 49 | # `on_` for your handler name. 50 | # 51 | # ``` 52 | # class MySubscriber 53 | # include Omnes::Subscriber[ 54 | # autodiscover: true 55 | # ] 56 | # 57 | # def on_foo(event) 58 | # # do_something 59 | # end 60 | # end 61 | # ``` 62 | # 63 | # If you prefer, you can make `autodiscover` on by default: 64 | # 65 | # ``` 66 | # Omnes.config.subscriber.autodiscover = true 67 | # ``` 68 | # 69 | # You can specify your own autodiscover strategy. It must be something 70 | # callable, transforming the event name into the handler name. 71 | # 72 | # ``` 73 | # AUTODISCOVER_STRATEGY = ->(event_name) { event_name } 74 | # 75 | # class MySubscriber 76 | # include Omnes::Subscriber[ 77 | # autodiscover: true, 78 | # autodiscover_strategy: AUTODISCOVER_STRATEGY 79 | # ] 80 | # 81 | # def foo(event) 82 | # # do_something 83 | # end 84 | # end 85 | # ``` 86 | # You're not limited to using method names as event handlers. You can create 87 | # your own adapters from the subscriber instance context. 88 | # 89 | # ``` 90 | # ADAPTER = lambda do |instance, event| 91 | # event.foo? ? instance.foo_true(event) : instance.foo_false(event) 92 | # end 93 | # 94 | # class MySubscriber 95 | # include Omnes::Subscriber 96 | # 97 | # handle :my_event, with: ADAPTER 98 | # 99 | # def foo_true(event) 100 | # # do_something 101 | # end 102 | # 103 | # def foo_false(event) 104 | # # do_something_else 105 | # end 106 | # end 107 | # ``` 108 | # 109 | # Subscriber adapters can be leveraged to build integrations with background 110 | # job libraries. See {Omnes::Subscriber::Adapter} for what comes shipped with 111 | # the library. 112 | # 113 | # Once you've defined the event handlers, you can subscribe to a {Omnes::Bus} 114 | # instance: 115 | # 116 | # ``` 117 | # MySubscriber.new.subscribe_to(bus) 118 | # ``` 119 | # 120 | # Notice that a subscriber instance can only be subscribed once to the same 121 | # bus. However, you can subscribe distinct instances to the same bus or the 122 | # same instance to different buses. 123 | module Subscriber 124 | extend Configurable 125 | 126 | # @api private 127 | ON_PREFIX_STRATEGY = ->(event_name) { :"on_#{event_name}" } 128 | 129 | setting :autodiscover, default: false 130 | setting :autodiscover_strategy, default: ON_PREFIX_STRATEGY 131 | nest_config Adapter 132 | 133 | # Includes with options 134 | # 135 | # ``` 136 | # include Omnes::Subscriber[autodiscover: true] 137 | # ``` 138 | # 139 | # Use regular `include Omnes::Subscriber` in case you want to use the 140 | # defaults (which can be changed through configuration). 141 | # 142 | # @param autodiscover [Boolean] 143 | # @param autodiscover_strategy [#call] 144 | def self.[](autodiscover: config.autodiscover, autodiscover_strategy: config.autodiscover_strategy) 145 | Module.new(autodiscover_strategy: autodiscover ? autodiscover_strategy : nil) 146 | end 147 | 148 | # @api private 149 | def self.included(klass) 150 | klass.include(self.[]) 151 | end 152 | 153 | # @api private 154 | class Module < ::Module 155 | attr_reader :autodiscover_strategy 156 | 157 | def initialize(autodiscover_strategy:) 158 | @autodiscover_strategy = autodiscover_strategy 159 | super() 160 | end 161 | 162 | def included(klass) 163 | klass.instance_variable_set(:@_mutex, Mutex.new) 164 | klass.instance_variable_set(:@_state, State.new(autodiscover_strategy: autodiscover_strategy)) 165 | klass.extend(ClassMethods) 166 | klass.include(InstanceMethods) 167 | end 168 | end 169 | 170 | # Instance methods included in a {Omnes::Subscriber} 171 | module InstanceMethods 172 | # Subscribes event handlers to a bus 173 | # 174 | # @param bus [Omnes::Bus] 175 | # 176 | # @return [Omnes::Subscriber::Subscribers] 177 | # 178 | # @raise [Omnes::Subscriber::UnknownMethodSubscriptionAttemptError] when 179 | # subscribing a method that doesn't exist 180 | # @raise [Omnes::Subscriber::PrivateMethodSubscriptionAttemptError] when 181 | # trying to subscribe a method that is private 182 | # @raise [Omnes::Subscriber::DuplicateSubscriptionAttemptError] when 183 | # trying to subscribe to the same event with the same method more than once 184 | def subscribe_to(bus) 185 | self.class.instance_variable_get(:@_state).public_send(:call, bus, self) 186 | end 187 | end 188 | 189 | # Included DSL methods for a {Omnes::Subscriber} 190 | module ClassMethods 191 | # Match a single event name 192 | # 193 | # @param event_name [Symbol] 194 | # @param with [Symbol, #call] Public method in the class or an adapter 195 | # @param id [Symbol] Unique identifier for the subscription 196 | def handle(event_name, with:, id: Subscription.random_id) 197 | @_mutex.synchronize do 198 | @_state.add_subscription_definition do |bus, instance| 199 | bus.registry.check_event_name(event_name) 200 | [Subscription::SINGLE_EVENT_MATCHER.curry[event_name], Adapter.Type(with), State.IdType(id).(instance)] 201 | end 202 | end 203 | end 204 | 205 | # Handles all events 206 | # 207 | # @param with [Symbol, #call] Public method in the class or an adapter 208 | # @param id [Symbol] Unique identifier for the subscription 209 | def handle_all(with:, id: Subscription.random_id) 210 | @_mutex.synchronize do 211 | @_state.add_subscription_definition do |_bus, instance| 212 | [Subscription::ALL_EVENTS_MATCHER, Adapter.Type(with), State.IdType(id).(instance)] 213 | end 214 | end 215 | end 216 | 217 | # Handles events with a custom matcher 218 | # 219 | # @param matcher [#call] 220 | # @param with [Symbol, #call] Public method in the class or an adapter 221 | # @param id [Symbol] Unique identifier for the subscription 222 | def handle_with_matcher(matcher, with:, id: Subscription.random_id) 223 | @_mutex.synchronize do 224 | @_state.add_subscription_definition do |_bus, instance| 225 | [matcher, Adapter.Type(with), State.IdType(id).(instance)] 226 | end 227 | end 228 | end 229 | end 230 | end 231 | end 232 | -------------------------------------------------------------------------------- /lib/omnes/subscriber/adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "omnes/configurable" 4 | require "omnes/subscriber/adapter/active_job" 5 | require "omnes/subscriber/adapter/method" 6 | require "omnes/subscriber/adapter/sidekiq" 7 | 8 | module Omnes 9 | module Subscriber 10 | # Adapters to build {Omnes::Subscription}'s callbacks 11 | # 12 | # Adapters need to implement a method `#call` taking the instance of 13 | # {Omnes::Subscriber} and the event. 14 | # 15 | # Alternatively, they can be curried and only take the instance as an 16 | # argument, returning a one-argument callable taking the event. 17 | module Adapter 18 | extend Configurable 19 | 20 | nest_config Sidekiq 21 | nest_config ActiveJob 22 | 23 | # @api private 24 | # TODO: Simplify when when we can take callables and Proc in a polymorphic 25 | # way: https://bugs.ruby-lang.org/issues/18644 26 | # > builder.to_proc.curry[instance] 27 | def self.Type(value) 28 | case value 29 | when Symbol 30 | Type(Method.new(value)) 31 | when Proc 32 | value 33 | else 34 | value.method(:call) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/omnes/subscriber/adapter/active_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "omnes/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 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, publication_context) 56 | self.[].(instance, event, publication_context) 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, publication_context) 68 | if Subscription.takes_publication_context?(instance.method(:perform)) 69 | instance.class.perform_later(serializer.(event), publication_context.serialized) 70 | else 71 | instance.class.perform_later(serializer.(event)) 72 | end 73 | end 74 | end 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/omnes/subscriber/adapter/method.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "omnes/subscriber/adapter/method/errors" 4 | 5 | module Omnes 6 | module Subscriber 7 | module Adapter 8 | # Builds a callback from a method of the instance 9 | # 10 | # You can use an instance of this class as the adapter: 11 | # 12 | # ```ruby 13 | # handle :foo, with: Adapter::Method.new(:foo) 14 | # ``` 15 | # 16 | # However, you can short-circuit with a {Symbol}. 17 | # 18 | # ```ruby 19 | # handle :foo, with: :foo 20 | # ``` 21 | class Method 22 | attr_reader :name 23 | 24 | def initialize(name) 25 | @name = name 26 | end 27 | 28 | # @api private 29 | def call(instance) 30 | check_method(instance) 31 | 32 | instance.method(name) 33 | end 34 | 35 | private 36 | 37 | def check_method(instance) 38 | raise PrivateMethodSubscriptionAttemptError.new(method_name: name) if instance.private_methods.include?(name) 39 | 40 | raise UnknownMethodSubscriptionAttemptError.new(method_name: name) unless instance.methods.include?(name) 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/omnes/subscriber/adapter/method/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "omnes/errors" 4 | 5 | module Omnes 6 | module Subscriber 7 | module Adapter 8 | class Method 9 | # Raised when trying to subscribe to a missing method 10 | class UnknownMethodSubscriptionAttemptError < Omnes::Error 11 | attr_reader :method_name 12 | 13 | # @api private 14 | def initialize(method_name:) 15 | @method_name = method_name 16 | super(default_message) 17 | end 18 | 19 | private 20 | 21 | def default_message 22 | <<~MSG 23 | You tried to subscribe an unexisting "#{method_name}" method. Event 24 | handlers need to be public methods on the subscriber class. 25 | MSG 26 | end 27 | end 28 | 29 | # Raised when trying to subscribe to a private method 30 | class PrivateMethodSubscriptionAttemptError < Omnes::Error 31 | attr_reader :method_name 32 | 33 | # @api private 34 | def initialize(method_name:) 35 | @method_name = method_name 36 | super(default_message) 37 | end 38 | 39 | private 40 | 41 | def default_message 42 | <<~MSG 43 | You tried to subscribe "#{method_name}" private method. Event handlers 44 | need to be public methods on the subscriber class. 45 | MSG 46 | end 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/omnes/subscriber/adapter/sidekiq.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "omnes/configurable" 4 | 5 | module Omnes 6 | module Subscriber 7 | module Adapter 8 | # [Sidekiq](https://sidekiq.org/) adapter 9 | # 10 | # Builds subscription to be processed as Sidekiq's background jobs. 11 | # 12 | # Sidekiq requires that the argument passed to `#perform` is serializable. 13 | # By default, the result of calling `#payload` in the event is taken. 14 | # 15 | # ``` 16 | # class MySubscriber 17 | # include Omnes::Subscriber 18 | # include Sidekiq::Job 19 | # 20 | # handle :my_event, with: Adapter::Sidekiq 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 | # 32 | # However, you can configure how the event is serialized thanks to the 33 | # `serializer:` option. It needs to be something callable taking the 34 | # event as argument: 35 | # 36 | # ``` 37 | # handle :my_event, with: Adapter::Sidekiq[serializer: :serialized_payload.to_proc] 38 | # ``` 39 | # 40 | # You can also globally configure the default serializer: 41 | # 42 | # ``` 43 | # Omnes.config.subscriber.adapter.sidekiq.serializer = :serialized_payload.to_proc 44 | # ``` 45 | # 46 | # You can delay the callback execution from the publication time with the 47 | # {.in} method (analogous to {Sidekiq::Job.perform_in}). 48 | # 49 | # @example 50 | # handle :my_event, with: Adapter::Sidekiq.in(60) 51 | module Sidekiq 52 | extend Configurable 53 | 54 | setting :serializer, default: :payload.to_proc 55 | 56 | # @param serializer [#call] 57 | def self.[](serializer: config.serializer) 58 | Instance.new(serializer: serializer) 59 | end 60 | 61 | # @api private 62 | def self.call(instance, event, publication_context) 63 | self.[].(instance, event, publication_context) 64 | end 65 | 66 | # @param seconds [Integer] 67 | def self.in(seconds) 68 | self.[].in(seconds) 69 | end 70 | 71 | # @api private 72 | class Instance 73 | attr_reader :serializer 74 | 75 | def initialize(serializer:) 76 | @serializer = serializer 77 | end 78 | 79 | def call(instance, event, publication_context) 80 | if takes_publication_context?(instance) 81 | instance.class.perform_async(serializer.(event), publication_context.serialized) 82 | else 83 | instance.class.perform_async(serializer.(event)) 84 | end 85 | end 86 | 87 | def in(seconds) 88 | lambda do |instance, event, publication_context| 89 | if takes_publication_context?(instance) 90 | instance.class.perform_in(seconds, serializer.(event), publication_context.serialized) 91 | else 92 | instance.class.perform_in(seconds, serializer.(event)) 93 | end 94 | end 95 | end 96 | 97 | private 98 | 99 | def takes_publication_context?(instance) 100 | Subscription.takes_publication_context?(instance.method(:perform)) 101 | end 102 | end 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/omnes/subscriber/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "omnes/errors" 4 | 5 | module Omnes 6 | module Subscriber 7 | # Raised when subscribing the same subscriber instance to the same bus twice 8 | class MultipleSubscriberSubscriptionAttemptError < Omnes::Error 9 | # @api private 10 | def initialize 11 | super(default_message) 12 | end 13 | 14 | private 15 | 16 | def default_message 17 | <<~MSG 18 | Omnes::Subscriber#subscribe_to method can only be called once for a 19 | given instance on a given bus 20 | MSG 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/omnes/subscriber/state.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "omnes/subscriber/adapter" 4 | require "omnes/subscriber/errors" 5 | require "omnes/subscription" 6 | 7 | module Omnes 8 | module Subscriber 9 | # @api private 10 | class State 11 | attr_reader :subscription_definitions, :calling_cache, :autodiscover_strategy 12 | 13 | # @api private 14 | def self.IdType(value) 15 | value.respond_to?(:call) ? value : ->(_instance) { value } 16 | end 17 | 18 | def initialize(autodiscover_strategy:, subscription_definitions: [], calling_cache: []) 19 | @subscription_definitions = subscription_definitions 20 | @calling_cache = calling_cache 21 | @autodiscover_strategy = autodiscover_strategy 22 | end 23 | 24 | def call(bus, instance) 25 | raise MultipleSubscriberSubscriptionAttemptError if already_called?(bus, instance) 26 | 27 | all_subscription_definitions = subscription_definitions + autodiscovered_subscription_definitions(bus, instance) 28 | 29 | definitions = all_subscription_definitions.map { |defn| defn.(bus, instance) } 30 | 31 | subscribe_definitions(definitions, bus, instance).tap do 32 | mark_as_called(bus, instance) 33 | end 34 | end 35 | 36 | def add_subscription_definition(&block) 37 | @subscription_definitions << block 38 | end 39 | 40 | private 41 | 42 | def already_called?(bus, instance) 43 | calling_cache.include?([bus, instance]) 44 | end 45 | 46 | def mark_as_called(bus, instance) 47 | @calling_cache << [bus, instance] 48 | end 49 | 50 | def autodiscovered_subscription_definitions(bus, instance) 51 | return [] unless autodiscover_strategy 52 | 53 | bus.registry.event_names.reduce([]) do |defs, event_name| 54 | method_name = autodiscover_strategy.(event_name) 55 | if instance.respond_to?(method_name, true) 56 | [ 57 | *defs, 58 | autodiscovered_subscription_definition(event_name, method_name) 59 | ] 60 | else 61 | defs 62 | end 63 | end 64 | end 65 | 66 | def autodiscovered_subscription_definition(event_name, method_name) 67 | lambda do |_bus, _instance| 68 | [ 69 | Subscription::SINGLE_EVENT_MATCHER.curry[event_name], 70 | Adapter.Type(Adapter::Method.new(method_name)), 71 | Subscription.random_id 72 | ] 73 | end 74 | end 75 | 76 | def subscribe_definitions(definitions, bus, instance) 77 | matcher_with_callbacks = definitions.map do |(matcher, adapter, id)| 78 | [matcher, adapter.curry[instance], id] 79 | end 80 | 81 | matcher_with_callbacks.map { |matcher, callback, id| bus.subscribe_with_matcher(matcher, callback, id: id) } 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/omnes/subscription.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "benchmark" 4 | require "omnes/execution" 5 | require "securerandom" 6 | 7 | module Omnes 8 | # Subscription to an event 9 | # 10 | # An instance of it is returned on {Omnes::Bus} subscription methods. 11 | # 12 | # Usually, it isn't used directly beyond as a reference to unsubscribe. 13 | # 14 | # ``` 15 | # bus = Omnes::Bus.new 16 | # bus.register(:foo) 17 | # subscription = bus.subscribe(:foo) { |_event| do_something } 18 | # bus.unsubscribe(subscription) 19 | # ``` 20 | class Subscription 21 | SINGLE_EVENT_MATCHER = lambda do |subscribed, candidate| 22 | subscribed == candidate.omnes_event_name 23 | end 24 | 25 | ALL_EVENTS_MATCHER = ->(_candidate) { true } 26 | 27 | # @api private 28 | def self.random_id 29 | SecureRandom.uuid.to_sym 30 | end 31 | 32 | # @api private 33 | def self.takes_publication_context?(callable) 34 | callable.parameters.count == 2 35 | end 36 | 37 | # @api private 38 | attr_reader :matcher, :callback, :id 39 | 40 | # @api private 41 | def initialize(matcher:, callback:, id:) 42 | raise Omnes::InvalidSubscriptionNameError.new(id: id) unless id.is_a?(Symbol) 43 | 44 | @matcher = matcher 45 | @callback = callback 46 | @id = id 47 | end 48 | 49 | # @api private 50 | def call(event, publication_context) 51 | result = nil 52 | benchmark = Benchmark.measure do 53 | # work around Ruby not being able to tell remaining arity for a curried 54 | # function (or uncurrying), because we want to be able to create subscriber 55 | # adapters partially applying the subscriber instance 56 | result = begin 57 | @callback.(event, publication_context) 58 | rescue ArgumentError 59 | @callback.(event) 60 | end 61 | end 62 | 63 | Execution.new(subscription: self, result: result, benchmark: benchmark) 64 | end 65 | 66 | # @api private 67 | def matches?(candidate) 68 | matcher.(candidate) 69 | end 70 | 71 | # Returns self within a single-item array 72 | # 73 | # This method can be helpful to act polymorphic to an array of subscriptions 74 | # from an {Omnes::Subscriber}, usually for testing purposes. 75 | def subscriptions 76 | [self] 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/omnes/unstructured_event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Omnes 4 | # Event with a payload defined at publication time 5 | # 6 | # An instance of it is automatically created on {Omnes::Bus#publish} when a 7 | # name and payload are given. 8 | # 9 | # @example 10 | # bus = Omnes::Bus.new 11 | # bus.register(:foo) 12 | # bus.subscribe(:foo) do |event| 13 | # puts event[:bar] 14 | # end 15 | # bus.publish(:foo, bar: 'bar') # it'll generate an instance of this class 16 | class UnstructuredEvent 17 | # Name of the event 18 | # 19 | # @return [Symbol] 20 | attr_reader :omnes_event_name 21 | 22 | # Information made available to the matching subscriptions 23 | # 24 | # @return [Hash] 25 | attr_reader :payload 26 | 27 | # @api private 28 | def initialize(payload:, omnes_event_name:) 29 | @payload = payload 30 | @omnes_event_name = omnes_event_name 31 | end 32 | 33 | # Delegates to {#payload} 34 | # 35 | # @param key [Any] 36 | # 37 | # @return Any 38 | def [](key) 39 | payload[key] 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/omnes/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Omnes 4 | VERSION = "0.2.2" 5 | end 6 | -------------------------------------------------------------------------------- /omnes.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/omnes/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "omnes" 7 | spec.version = Omnes::VERSION 8 | spec.authors = ["Marc Busqué"] 9 | spec.email = ["marc@lamarciana.com"] 10 | 11 | spec.summary = "Pub/Sub for ruby" 12 | spec.description = <<~MSG 13 | Omnes is a Ruby library implementing the publish-subscribe pattern. This 14 | pattern allows senders of messages to be decoupled from their receivers. An 15 | Event Bus acts as a middleman where events are published while interested 16 | parties can subscribe to them. 17 | MSG 18 | spec.homepage = "https://github.com/nebulab/omnes" 19 | spec.license = "MIT" 20 | spec.required_ruby_version = Gem::Requirement.new(">= 3.0.0") 21 | 22 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 23 | 24 | spec.metadata["homepage_uri"] = spec.homepage 25 | spec.metadata["source_code_uri"] = spec.homepage 26 | spec.metadata["changelog_uri"] = "#{spec.homepage}/CHANGELOG.md" 27 | 28 | spec.metadata["rubygems_mfa_required"] = "true" 29 | 30 | # Specify which files should be added to the gem when it is released. 31 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 32 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 33 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 34 | end 35 | spec.bindir = "exe" 36 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 37 | spec.require_paths = ["lib"] 38 | end 39 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "omnes" 5 | 6 | RSpec.configure do |config| 7 | # Enable flags like --only-failures and --next-failure 8 | config.example_status_persistence_file_path = ".rspec_status" 9 | 10 | # Disable RSpec exposing methods globally on `Module` and `main` 11 | config.disable_monkey_patching! 12 | 13 | config.expect_with :rspec do |c| 14 | c.syntax = :expect 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/shared_examples/bus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples "bus" do 4 | let(:counter) do 5 | Class.new do 6 | attr_reader :count 7 | 8 | def initialize 9 | @count = 0 10 | end 11 | 12 | def inc 13 | @count += 1 14 | end 15 | end 16 | end 17 | 18 | describe "#register" do 19 | it "adds the event name to the register" do 20 | bus = subject.new 21 | 22 | bus.register(:foo) 23 | 24 | expect(bus.registry.registered?(:foo)).to be(true) 25 | end 26 | 27 | it "provides caller location to the registration" do 28 | bus = subject.new 29 | 30 | bus.register(:foo) 31 | 32 | expect(bus.registry.registration(:foo).caller_location.to_s).to include(__FILE__) 33 | end 34 | 35 | it "raises when the event name is already registered" do 36 | bus = subject.new 37 | bus.register(:foo, caller_location: caller_locations(0)[0]) 38 | 39 | expect { 40 | bus.register(:foo) 41 | }.to raise_error(Omnes::AlreadyRegisteredEventError, /already registered.*#{__FILE__}/m) 42 | end 43 | end 44 | 45 | describe "#publish" do 46 | it "executes subscriptions matching given event name" do 47 | bus = subject.new 48 | dummy = counter.new 49 | bus.register(:foo) 50 | bus.subscribe(:foo) { dummy.inc } 51 | 52 | bus.publish :foo 53 | 54 | expect(dummy.count).to be(1) 55 | end 56 | 57 | it "doesn't execute subscriptions that don't match" do 58 | bus = subject.new 59 | dummy = counter.new 60 | bus.register(:bar) 61 | bus.subscribe(:bar) { dummy.inc } 62 | bus.register(:foo) 63 | 64 | bus.publish :foo 65 | 66 | expect(dummy.count).to be(0) 67 | end 68 | 69 | it "can publish an unstructured event yielding given kwargs to the subscription as the event payload" do 70 | bus = subject.new 71 | dummy = Class.new do 72 | attr_accessor :box 73 | end.new 74 | bus.register(:foo) 75 | bus.subscribe(:foo) { |event| dummy.box = event.payload[:box] } 76 | 77 | bus.publish :foo, box: "foo" 78 | 79 | expect(dummy.box).to eq("foo") 80 | end 81 | 82 | it "can publish an event instance including Omnes::Event, yielding it to the subscription" do 83 | FooEvent = Class.new do 84 | include Omnes::Event 85 | 86 | def bar 87 | :bar 88 | end 89 | end 90 | dummy = Class.new do 91 | attr_accessor :box 92 | end.new 93 | bus = subject.new 94 | bus.register(:foo) 95 | bus.subscribe(:foo) { |event| dummy.box = event.bar } 96 | 97 | bus.publish FooEvent.new 98 | 99 | expect(dummy.box).to eq(:bar) 100 | ensure 101 | Object.send(:remove_const, :FooEvent) 102 | end 103 | 104 | it "can publish an event instance with an omnes_event_name method, yielding it to the subscription" do 105 | my_event = Class.new do 106 | def omnes_event_name 107 | :foo 108 | end 109 | 110 | def bar 111 | :bar 112 | end 113 | end 114 | dummy = Class.new do 115 | attr_accessor :box 116 | end.new 117 | bus = subject.new 118 | bus.register(:foo) 119 | bus.subscribe(:foo) { |event| dummy.box = event.bar } 120 | 121 | bus.publish my_event.new 122 | 123 | expect(dummy.box).to eq(:bar) 124 | end 125 | 126 | it "yields the publication context as second parameter for the subscription" do 127 | bus = subject.new 128 | bus.register(:foo) 129 | bus.subscribe(:foo) do |_event, publication_context| 130 | expect(publication_context.is_a?(Omnes::PublicationContext)).to be(true) 131 | end 132 | 133 | bus.publish(:foo) 134 | end 135 | 136 | it "adds the caller location to the provided publication context" do 137 | bus = subject.new 138 | bus.register(:foo) 139 | bus.subscribe(:foo) do |_event, publication_context| 140 | expect(publication_context.caller_location.to_s).to include(__FILE__) 141 | end 142 | 143 | bus.publish(:foo) 144 | end 145 | 146 | it "adds publication time to the provided publication context" do 147 | bus = subject.new 148 | bus.register(:foo) 149 | bus.subscribe(:foo) do |_event, publication_context| 150 | expect(publication_context.time).not_to be_nil 151 | end 152 | 153 | bus.publish(:foo) 154 | end 155 | 156 | it "returns a publication instance" do 157 | bus = subject.new 158 | bus.register(:foo) 159 | bus.subscribe(:foo) { :work } 160 | 161 | publication = bus.publish :foo 162 | 163 | expect(publication.is_a?(Omnes::Publication)).to be(true) 164 | end 165 | 166 | it "adds the published event to the publication result object" do 167 | bus = subject.new 168 | bus.register(:foo) 169 | bus.subscribe(:foo) { :work } 170 | 171 | publication = bus.publish :foo 172 | 173 | expect(publication.event.is_a?(Omnes::UnstructuredEvent)).to be(true) 174 | end 175 | 176 | it "adds the triggered executions to the publication result object" do 177 | bus = subject.new 178 | dummy = counter.new 179 | bus.register(:foo) 180 | subscription1 = bus.subscribe(:foo) { dummy.inc } 181 | subscription2 = bus.subscribe(:foo) { dummy.inc } 182 | 183 | publication = bus.publish :foo 184 | 185 | executions = publication.executions 186 | expect(executions.count).to be(2) 187 | expect(executions.map(&:subscription)).to match([subscription1, subscription2]) 188 | expect(executions.map(&:result)).to match([1, 2]) 189 | end 190 | 191 | it "adds the context to the publication result object" do 192 | bus = subject.new 193 | bus.register(:foo) 194 | bus.subscribe(:foo) { :work } 195 | 196 | publication = bus.publish :foo 197 | 198 | expect(publication.context.is_a?(Omnes::PublicationContext)).to be(true) 199 | end 200 | 201 | it "raises when the published event hasn't been registered" do 202 | bus = subject.new 203 | 204 | expect { 205 | bus.publish(:foo) 206 | }.to raise_error(Omnes::UnknownEventError, /not registered/) 207 | end 208 | end 209 | 210 | describe "#subscribe_with_matcher" do 211 | let(:true_matcher) { ->(_candidate) { true } } 212 | let(:false_matcher) { ->(_candidate) { false } } 213 | 214 | it "can subscribe as a block" do 215 | bus = subject.new 216 | bus.register(:foo) 217 | 218 | bus.subscribe_with_matcher(true_matcher) { :foo } 219 | 220 | subscription = bus.subscriptions.first 221 | expect(subscription.callback.()).to be(:foo) 222 | end 223 | 224 | it "can subscribe as anything callable" do 225 | bus = subject.new 226 | bus.register(:foo) 227 | callable = proc { :foo } 228 | 229 | bus.subscribe_with_matcher(true_matcher, callable) 230 | 231 | subscription = bus.subscriptions.first 232 | expect(subscription.callback.()).to be(:foo) 233 | end 234 | 235 | it "callable takes precedence over block" do 236 | bus = subject.new 237 | bus.register(:foo) 238 | callable = proc { :foo } 239 | 240 | bus.subscribe_with_matcher(true_matcher, callable) { :bar } 241 | 242 | subscription = bus.subscriptions.first 243 | expect(subscription.callback.()).to be(:foo) 244 | end 245 | 246 | it "can provide an identifier for the subscription" do 247 | bus = subject.new 248 | bus.register(:foo) 249 | 250 | subscription = bus.subscribe_with_matcher(true_matcher, id: :foo_subscription) { :foo } 251 | 252 | expect(bus.subscription(:foo_subscription)).to be(subscription) 253 | end 254 | 255 | it "raises when given subscription id has already been used" do 256 | bus = subject.new 257 | bus.register(:foo) 258 | 259 | bus.subscribe_with_matcher(true_matcher, id: :foo_subscription) { :foo } 260 | 261 | expect { 262 | bus.subscribe_with_matcher(true_matcher, id: :foo_subscription) { :foo } 263 | }.to raise_error(Omnes::DuplicateSubscriptionIdError) 264 | end 265 | 266 | it "runs when matcher returns true" do 267 | dummy = counter.new 268 | bus = subject.new 269 | bus.register(:foo) 270 | 271 | bus.subscribe_with_matcher(true_matcher) { dummy.inc } 272 | bus.publish(:foo) 273 | 274 | expect(dummy.count).to be(1) 275 | end 276 | 277 | it "doesn't run when matcher returns false" do 278 | dummy = counter.new 279 | bus = subject.new 280 | bus.register(:foo) 281 | 282 | bus.subscribe_with_matcher(false_matcher) { dummy.inc } 283 | bus.publish(:foo) 284 | 285 | expect(dummy.count).to be(0) 286 | end 287 | end 288 | 289 | describe "#subscribe" do 290 | it "can subscribe as a block" do 291 | bus = subject.new 292 | bus.register(:foo) 293 | 294 | bus.subscribe(:foo) { :foo } 295 | 296 | subscription = bus.subscriptions.first 297 | expect(subscription.callback.()).to be(:foo) 298 | end 299 | 300 | it "can subscribe as anything callable" do 301 | bus = subject.new 302 | bus.register(:foo) 303 | callable = proc { :foo } 304 | 305 | bus.subscribe(:foo, callable) 306 | 307 | subscription = bus.subscriptions.first 308 | expect(subscription.callback.()).to be(:foo) 309 | end 310 | 311 | it "callable takes precedence over block" do 312 | bus = subject.new 313 | bus.register(:foo) 314 | callable = proc { :foo } 315 | 316 | bus.subscribe(:foo, callable) { :bar } 317 | 318 | subscription = bus.subscriptions.first 319 | expect(subscription.callback.()).to be(:foo) 320 | end 321 | 322 | it "can provide an identifier for the subscription" do 323 | bus = subject.new 324 | bus.register(:foo) 325 | 326 | subscription = bus.subscribe(:foo, id: :foo_subscription) { :foo } 327 | 328 | expect(bus.subscription(:foo_subscription)).to be(subscription) 329 | end 330 | 331 | it "raises when given subscription id has already been used" do 332 | bus = subject.new 333 | bus.register(:foo) 334 | 335 | bus.subscribe(:foo, id: :foo_subscription) { :foo } 336 | 337 | expect { 338 | bus.subscribe(:foo, id: :foo_subscription) { :foo } 339 | }.to raise_error(Omnes::DuplicateSubscriptionIdError) 340 | end 341 | 342 | it "runs when published event matches" do 343 | dummy = counter.new 344 | bus = subject.new 345 | bus.register(:foo) 346 | 347 | bus.subscribe(:foo) { dummy.inc } 348 | bus.publish(:foo) 349 | 350 | expect(dummy.count).to be(1) 351 | end 352 | 353 | it "doesn't run when published event doesn't match" do 354 | dummy = counter.new 355 | bus = subject.new 356 | bus.register(:foo) 357 | bus.register(:bar) 358 | 359 | bus.subscribe(:foo) { dummy.inc } 360 | bus.publish(:bar) 361 | 362 | expect(dummy.count).to be(0) 363 | end 364 | 365 | it "raises when given event name hasn't been registered" do 366 | bus = subject.new 367 | 368 | expect { 369 | bus.subscribe(:foo) 370 | }.to raise_error(Omnes::UnknownEventError, /not registered/) 371 | end 372 | end 373 | 374 | describe "#subscribe_to_all" do 375 | it "can subscribe as a block" do 376 | bus = subject.new 377 | bus.register(:foo) 378 | 379 | bus.subscribe_to_all { :foo } 380 | 381 | subscription = bus.subscriptions.first 382 | expect(subscription.callback.()).to be(:foo) 383 | end 384 | 385 | it "can subscribe as anything callable" do 386 | bus = subject.new 387 | bus.register(:foo) 388 | callable = proc { :foo } 389 | 390 | bus.subscribe_to_all(callable) 391 | 392 | subscription = bus.subscriptions.first 393 | expect(subscription.callback.()).to be(:foo) 394 | end 395 | 396 | it "callable takes precedence over block" do 397 | bus = subject.new 398 | bus.register(:foo) 399 | callable = proc { :foo } 400 | 401 | bus.subscribe_to_all(callable) { :bar } 402 | 403 | subscription = bus.subscriptions.first 404 | expect(subscription.callback.()).to be(:foo) 405 | end 406 | 407 | it "can provide an identifier for the subscription" do 408 | bus = subject.new 409 | bus.register(:foo) 410 | 411 | subscription = bus.subscribe_to_all(id: :foo_subscription) { :foo } 412 | 413 | expect(bus.subscription(:foo_subscription)).to be(subscription) 414 | end 415 | 416 | it "raises when given subscription id has already been used" do 417 | bus = subject.new 418 | bus.register(:foo) 419 | 420 | bus.subscribe_to_all(id: :foo_subscription) { :foo } 421 | 422 | expect { 423 | bus.subscribe_to_all(id: :foo_subscription) { :foo } 424 | }.to raise_error(Omnes::DuplicateSubscriptionIdError) 425 | end 426 | 427 | it "runs for every event" do 428 | dummy = counter.new 429 | bus = subject.new 430 | bus.register(:foo) 431 | 432 | bus.subscribe_to_all { dummy.inc } 433 | bus.publish(:foo) 434 | 435 | expect(dummy.count).to be(1) 436 | end 437 | end 438 | 439 | describe "#unsubscribe" do 440 | it "removes given subscription" do 441 | bus = subject.new 442 | dummy = counter.new 443 | bus.register(:foo) 444 | subscription = bus.subscribe(:foo) { dummy.inc } 445 | 446 | bus.unsubscribe subscription 447 | bus.publish :foo 448 | 449 | expect(dummy.count).to be(0) 450 | end 451 | end 452 | 453 | describe "#performing_only" do 454 | it "runs given subcriptions" do 455 | bus = subject.new 456 | bus.register(:foo) 457 | dummy = counter.new 458 | subscription = bus.subscribe(:foo) { dummy.inc } 459 | 460 | bus.performing_only(subscription) do 461 | bus.publish(:foo) 462 | end 463 | 464 | expect(dummy.count).to be(1) 465 | end 466 | 467 | it "doesn't run excluded subcriptions" do 468 | bus = subject.new 469 | bus.register(:foo) 470 | dummy = counter.new 471 | bus.subscribe(:foo) { dummy.inc } 472 | 473 | bus.performing_only do 474 | bus.publish(:foo) 475 | end 476 | 477 | expect(dummy.count).to be(0) 478 | end 479 | 480 | it "can run excluded subcriptions when the block is over" do 481 | bus = subject.new 482 | bus.register(:foo) 483 | dummy = counter.new 484 | bus.subscribe(:foo) { dummy.inc } 485 | 486 | bus.performing_only do 487 | bus.publish(:foo) 488 | end 489 | 490 | expect(dummy.count).to be(0) 491 | 492 | bus.publish(:foo) 493 | 494 | expect(dummy.count).to be(1) 495 | end 496 | 497 | it "restores old subcriptions when an exception is raised" do 498 | bus = subject.new 499 | bus.register(:foo) 500 | dummy = counter.new 501 | subscription = bus.subscribe(:foo) { raise "error" } 502 | bus.subscribe(:foo) { dummy.inc } 503 | 504 | bus.performing_only(subscription) do 505 | expect do 506 | bus.publish(:foo) 507 | end.to raise_error(RuntimeError) 508 | end 509 | 510 | bus.unsubscribe(subscription) 511 | bus.publish(:foo) 512 | 513 | expect(dummy.count).to be(1) 514 | end 515 | 516 | it "raises an error when the subscription is now known" do 517 | bus1 = subject.new 518 | bus2 = subject.new 519 | bus2.register(:foo) 520 | subscription = bus2.subscribe(:foo) 521 | 522 | expect do 523 | bus1.performing_only(subscription) 524 | end.to raise_error(Omnes::UnknownSubscriptionError) 525 | end 526 | end 527 | 528 | describe "#performing_nothing" do 529 | it "doesn't run any subcriptions" do 530 | bus = subject.new 531 | bus.register(:foo) 532 | dummy = counter.new 533 | bus.subscribe(:foo) { dummy.inc } 534 | 535 | bus.performing_nothing do 536 | bus.publish(:foo) 537 | end 538 | 539 | expect(dummy.count).to be(0) 540 | end 541 | end 542 | 543 | describe "#subscription" do 544 | it "fetchs a subscription by its id" do 545 | bus = subject.new 546 | bus.register(:foo) 547 | 548 | subscription = bus.subscribe(:foo, id: :foo_subs) { :foo } 549 | 550 | expect(bus.subscription(:foo_subs)).to be(subscription) 551 | end 552 | end 553 | 554 | describe "#clear" do 555 | it "removes subscriptions" do 556 | bus = subject.new 557 | bus.register(:foo) 558 | bus.subscribe(:foo) { :foo } 559 | 560 | bus.clear 561 | 562 | expect(bus.subscriptions.empty?).to be(true) 563 | end 564 | 565 | it "uses a pristine register" do 566 | bus = subject.new 567 | 568 | expect(bus.registry).not_to be(bus.clear.registry) 569 | end 570 | 571 | it "doesn't keep registrations in the new registry" do 572 | bus = subject.new 573 | bus.register(:foo) 574 | 575 | expect(bus.clear.registry.registered?(:foo)).to be(false) 576 | end 577 | end 578 | end 579 | -------------------------------------------------------------------------------- /spec/unit/omnes/bus_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "omnes/bus" 5 | require "support/shared_examples/bus" 6 | 7 | RSpec.describe Omnes::Bus do 8 | subject { described_class } 9 | 10 | include_examples "bus" 11 | end 12 | -------------------------------------------------------------------------------- /spec/unit/omnes/configurable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "omnes/configurable" 4 | 5 | RSpec.describe Omnes::Configurable do 6 | subject { Class.new.extend(described_class) } 7 | 8 | describe ".config" do 9 | it "returns configuration class" do 10 | expect(subject.config.is_a?(described_class::Config)).to be(true) 11 | end 12 | end 13 | 14 | describe ".configure" do 15 | it "yields the configuration instance" do 16 | subject.configure do |config| 17 | expect(config).to be(subject.config) 18 | end 19 | end 20 | end 21 | 22 | describe ".setting" do 23 | it "sets default as the setting value" do 24 | subject.setting :foo, default: :bar 25 | 26 | expect(subject.config.settings[:foo]).to be(:bar) 27 | end 28 | 29 | it "creates a reader for the setting in config" do 30 | subject.setting :foo, default: :bar 31 | 32 | expect(subject.config.foo).to be(:bar) 33 | end 34 | 35 | it "creates a writter for the setting in config" do 36 | subject.setting :foo, default: :bar 37 | 38 | subject.config.foo = :baz 39 | 40 | expect(subject.config.foo).to be(:baz) 41 | end 42 | end 43 | 44 | describe ".nest_config" do 45 | it "adds a reader in config to access another constant config" do 46 | other = Class.new.extend(described_class) 47 | 48 | subject.nest_config other, name: :other 49 | 50 | expect(subject.config.other).to be(other.config) 51 | end 52 | 53 | it "defaults reader name to the downcased class name" do 54 | Other = Class.new.extend(described_class) 55 | 56 | subject.nest_config Other 57 | 58 | expect(subject.config.other).to be(Other.config) 59 | ensure 60 | Object.send(:remove_const, :Other) 61 | end 62 | 63 | it "adds an underscore before a capitalized character preceded by a lowercase char in the default name" do 64 | OtherClass = Class.new.extend(described_class) 65 | 66 | subject.nest_config OtherClass 67 | 68 | expect(subject.config.other_class).to be(OtherClass.config) 69 | ensure 70 | Object.send(:remove_const, :OtherClass) 71 | end 72 | 73 | it "only takes the last hierarchy level for the default name" do 74 | module Top 75 | Bottom = Class.new.extend(Omnes::Configurable) 76 | end 77 | 78 | subject.nest_config Top::Bottom 79 | 80 | expect(subject.config.bottom).to be(Top::Bottom.config) 81 | ensure 82 | Object.send(:remove_const, :Top) 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/unit/omnes/event_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "omnes/event" 4 | 5 | RSpec.describe Omnes::Event do 6 | describe "#omnes_event_name" do 7 | it "returns a symbol" do 8 | Foo = Class.new.include(Omnes::Event) 9 | 10 | expect(Foo.new.omnes_event_name.is_a?(Symbol)).to be(true) 11 | ensure 12 | Object.send(:remove_const, :Foo) 13 | end 14 | 15 | it "returns class name downcased" do 16 | Foo = Class.new.include(Omnes::Event) 17 | 18 | expect(Foo.new.omnes_event_name).to be(:foo) 19 | ensure 20 | Object.send(:remove_const, :Foo) 21 | end 22 | 23 | it "replaces module separator with underscores" do 24 | module Foo 25 | Bar = Class.new.include(Omnes::Event) 26 | end 27 | 28 | expect(Foo::Bar.new.omnes_event_name).to be(:foo_bar) 29 | ensure 30 | Object.send(:remove_const, :Foo) 31 | end 32 | 33 | it "replaces all module separators with underscores" do 34 | module Foo 35 | module Bar 36 | Baz = Class.new.include(Omnes::Event) 37 | end 38 | end 39 | 40 | expect(Foo::Bar::Baz.new.omnes_event_name).to be(:foo_bar_baz) 41 | ensure 42 | Object.send(:remove_const, :Foo) 43 | end 44 | 45 | it "adds an underscore before a capitalized character preceded by a lowercase char" do 46 | FooBar = Class.new.include(Omnes::Event) 47 | 48 | expect(FooBar.new.omnes_event_name).to be(:foo_bar) 49 | ensure 50 | Object.send(:remove_const, :FooBar) 51 | end 52 | 53 | it "adds an underscore before a capitalized character preceded by an uppercase char" do 54 | FBar = Class.new.include(Omnes::Event) 55 | 56 | expect(FBar.new.omnes_event_name).to be(:f_bar) 57 | ensure 58 | Object.send(:remove_const, :FBar) 59 | end 60 | 61 | it "removes an Event suffix if present" do 62 | FooEvent = Class.new.include(Omnes::Event) 63 | 64 | expect(FooEvent.new.omnes_event_name).to be(:foo) 65 | ensure 66 | Object.send(:remove_const, :FooEvent) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/unit/omnes/publication_context_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "omnes/publication_context" 5 | 6 | RSpec.describe Omnes::PublicationContext do 7 | describe ".serialized" do 8 | it "serializes caller_location as string" do 9 | context = described_class.new(caller_location: caller_locations(0)[0], time: Time.now) 10 | 11 | expect(context.serialized["caller_location"]).to include(__FILE__) 12 | end 13 | 14 | it "serializes time as string" do 15 | context = described_class.new(caller_location: caller_locations(0)[0], time: Time.new(2022, 10, 10)) 16 | 17 | expect(context.serialized["time"]).to include("2022-10-10") 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/unit/omnes/registry_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "omnes/registry" 5 | 6 | RSpec.describe Omnes::Registry do 7 | describe "#register" do 8 | it "adds given event name to the registry" do 9 | registry = described_class.new 10 | 11 | registry.register(:foo) 12 | 13 | expect(registry.registered?(:foo)).to be(true) 14 | end 15 | 16 | it "adds given caller location to the registration" do 17 | registry = described_class.new 18 | 19 | registry.register(:foo, caller_location: caller_locations(0)[0]) 20 | 21 | expect(registry.registration(:foo).caller_location.to_s).to include(__FILE__) 22 | end 23 | 24 | it "returns the registration" do 25 | registry = described_class.new 26 | 27 | registration = registry.register(:foo) 28 | 29 | expect(registration).to be_a(Omnes::Registry::Registration) 30 | end 31 | 32 | it "raises with the previos registration location when the event is already registered" do 33 | registry = described_class.new 34 | 35 | registry.register(:foo, caller_location: caller_locations(0)[0]) 36 | 37 | expect { 38 | registry.register(:foo) 39 | }.to raise_error(Omnes::AlreadyRegisteredEventError, /already registered.*#{__FILE__}/m) 40 | end 41 | 42 | it "raises when given event name is not a Symbol" do 43 | registry = described_class.new 44 | 45 | expect { 46 | registry.register("foo", caller_location: caller_locations(0)[0]) 47 | }.to raise_error(Omnes::InvalidEventNameError) 48 | end 49 | end 50 | 51 | describe "#unregister" do 52 | it "removes the given event name from the registry" do 53 | registry = described_class.new 54 | registry.register(:foo) 55 | 56 | registry.unregister(:foo) 57 | 58 | expect(registry.registered?(:foo)).to be(false) 59 | end 60 | 61 | it "raises with registered events when the event is not registered" do 62 | registry = described_class.new 63 | registry.register(:bar) 64 | 65 | expect { 66 | registry.unregister(:foo) 67 | }.to raise_error(Omnes::UnknownEventError, /not registered.*bar/m) 68 | end 69 | end 70 | 71 | describe "#registration" do 72 | it "finds the registration from given name" do 73 | registry = described_class.new 74 | 75 | registry.register(:foo) 76 | 77 | expect(registry.registration(:foo).event_name).to eq(:foo) 78 | end 79 | 80 | it "returns nil when the event name is not found" do 81 | registry = described_class.new 82 | 83 | expect(registry.registration(:foo)).to be_nil 84 | end 85 | end 86 | 87 | describe "#registered?" do 88 | it "returns true when given event name is registered" do 89 | registry = described_class.new 90 | registry.register(:foo) 91 | 92 | expect(registry.registered?(:foo)).to be(true) 93 | end 94 | 95 | it "returns false when given event name is not registered" do 96 | registry = described_class.new 97 | 98 | expect(registry.registered?(:foo)).to be(false) 99 | end 100 | end 101 | 102 | describe "#event_names" do 103 | it "returns array with the registered event names" do 104 | registry = described_class.new 105 | registry.register(:foo) 106 | registry.register(:bar) 107 | 108 | expect(registry.event_names).to match_array(%i[foo bar]) 109 | end 110 | end 111 | 112 | describe "#check_event_name" do 113 | it "raises when the event is not registered" do 114 | registry = described_class.new 115 | 116 | expect { 117 | registry.check_event_name(:foo) 118 | }.to raise_error(Omnes::UnknownEventError, /not registered/) 119 | end 120 | 121 | it "includes available events on the error message" do 122 | registry = described_class.new 123 | registry.register(:bar) 124 | registry.register(:baz) 125 | 126 | expect { 127 | registry.check_event_name(:foo) 128 | }.to raise_error(/'bar', 'baz'/) 129 | end 130 | 131 | if defined?(DidYouMean::PlainFormatter) 132 | it "hints on the event name on the error message" do 133 | registry = described_class.new 134 | registry.register(:foo) 135 | 136 | expect { 137 | registry.check_event_name(:fo) 138 | }.to raise_error(/Did you mean\? foo/) 139 | end 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /spec/unit/omnes/subscriber/adapter/active_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "active_job" 5 | require "active_job/test_helper" 6 | require "omnes/bus" 7 | require "omnes/subscriber" 8 | 9 | RSpec.describe Omnes::Subscriber::Adapter::ActiveJob do 10 | include ActiveJob::TestHelper 11 | 12 | let(:bus) { Omnes::Bus.new } 13 | 14 | before do 15 | ActiveJob::Base.queue_adapter = :test 16 | ActiveJob::Base.logger = Logger.new(nil) 17 | end 18 | 19 | it "performs the job async passing the event's payload" do 20 | class Subscriber < ActiveJob::Base 21 | include Omnes::Subscriber 22 | 23 | handle :create_foo, with: Adapter::ActiveJob 24 | 25 | def perform(payload) 26 | FOO_TABLE[payload["id"]] = payload["attributes"] 27 | end 28 | end 29 | FOO_TABLE = {} 30 | 31 | bus.register(:create_foo) 32 | Subscriber.new.subscribe_to(bus) 33 | event = Struct.new(:omnes_event_name, :payload).new(:create_foo, "id" => 1, "attributes" => { "name" => "foo" }) 34 | 35 | bus.publish(event) 36 | perform_enqueued_jobs 37 | 38 | expect(FOO_TABLE[1]).to eq("name" => "foo") 39 | ensure 40 | Object.send(:remove_const, :Subscriber) 41 | Object.send(:remove_const, :FOO_TABLE) 42 | end 43 | 44 | it "can specify how to serialize the event" do 45 | class Subscriber < ActiveJob::Base 46 | include Omnes::Subscriber 47 | 48 | EVENT_SERIALIZER = lambda do |event| 49 | { 50 | "id" => event.id, 51 | "attributes" => { 52 | "name" => event.attributes[:name] 53 | } 54 | } 55 | end 56 | 57 | handle :create_foo, with: Adapter::ActiveJob[serializer: EVENT_SERIALIZER] 58 | 59 | def perform(payload) 60 | FOO_TABLE[payload["id"]] = payload["attributes"] 61 | end 62 | end 63 | event = Struct.new(:omnes_event_name, :id, :attributes).new(:create_foo, 1, { name: "foo" }) 64 | FOO_TABLE = {} 65 | 66 | bus.register(:create_foo) 67 | Subscriber.new.subscribe_to(bus) 68 | 69 | bus.publish(event) 70 | perform_enqueued_jobs 71 | 72 | expect(FOO_TABLE[1]).to eq("name" => "foo") 73 | ensure 74 | Object.send(:remove_const, :Subscriber) 75 | Object.send(:remove_const, :FOO_TABLE) 76 | end 77 | 78 | it "can provide the serialized publication context" do 79 | class Subscriber < ActiveJob::Base 80 | include Omnes::Subscriber 81 | 82 | handle :foo, with: Adapter::ActiveJob 83 | 84 | def perform(_payload, publication_context) 85 | LOG[:publication_context] = publication_context 86 | end 87 | end 88 | LOG = {} 89 | 90 | bus.register(:foo) 91 | Subscriber.new.subscribe_to(bus) 92 | 93 | bus.publish(:foo) 94 | perform_enqueued_jobs 95 | 96 | expect(LOG[:publication_context].is_a?(Hash)).to be(true) 97 | ensure 98 | Object.send(:remove_const, :Subscriber) 99 | Object.send(:remove_const, :LOG) 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /spec/unit/omnes/subscriber/adapter/method_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "omnes/subscriber/adapter/method" 5 | 6 | RSpec.describe Omnes::Subscriber::Adapter::Method do 7 | let(:subscriber_class) do 8 | Class.new do 9 | include Omnes::Subscriber 10 | 11 | attr_reader :value 12 | 13 | def initialize 14 | @value = nil 15 | end 16 | end 17 | end 18 | let(:bus) { Omnes::Bus.new } 19 | 20 | it "uses given method as handler" do 21 | subscriber_class.class_eval do 22 | include Omnes::Subscriber 23 | 24 | handle :foo, with: :foo 25 | 26 | def foo(event) 27 | @value = event[:value] 28 | end 29 | end 30 | 31 | bus.register(:foo) 32 | subscriber = subscriber_class.new 33 | subscriber.subscribe_to(bus) 34 | bus.publish(:foo, value: :bar) 35 | 36 | expect(subscriber.value).to be(:bar) 37 | end 38 | 39 | it "provides publication context if the method takes a second parameter" do 40 | subscriber_class.class_eval do 41 | include Omnes::Subscriber 42 | 43 | handle :foo, with: :foo 44 | 45 | def foo(_event, publication_context) 46 | @value = publication_context 47 | end 48 | end 49 | 50 | bus.register(:foo) 51 | subscriber = subscriber_class.new 52 | subscriber.subscribe_to(bus) 53 | bus.publish(:foo, value: :bar) 54 | 55 | expect(subscriber.value.is_a?(Omnes::PublicationContext)).to be(true) 56 | end 57 | 58 | it "raises when method is private" do 59 | subscriber_class.class_eval do 60 | include Omnes::Subscriber 61 | 62 | handle :foo, with: :foo 63 | 64 | private def foo(_event); end 65 | end 66 | 67 | bus.register(:foo) 68 | subscriber = subscriber_class.new 69 | 70 | expect { 71 | subscriber.subscribe_to(bus) 72 | }.to raise_error( 73 | described_class::PrivateMethodSubscriptionAttemptError, 74 | /"foo" private method/m 75 | ) 76 | end 77 | 78 | it "raises when method doesn't exist" do 79 | subscriber_class.class_eval do 80 | include Omnes::Subscriber 81 | 82 | handle :foo, with: :foo 83 | end 84 | 85 | bus.register(:foo) 86 | subscriber = subscriber_class.new 87 | 88 | expect { 89 | subscriber.subscribe_to(bus) 90 | }.to raise_error( 91 | described_class::UnknownMethodSubscriptionAttemptError, 92 | /"foo" method/m 93 | ) 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /spec/unit/omnes/subscriber/adapter/sidekiq_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "omnes/bus" 5 | require "omnes/subscriber" 6 | require "sidekiq/testing" 7 | 8 | RSpec.describe Omnes::Subscriber::Adapter::Sidekiq do 9 | before do 10 | Sidekiq.strict_args! 11 | Sidekiq::Testing.inline! 12 | end 13 | 14 | let(:bus) { Omnes::Bus.new } 15 | 16 | it "performs the job async passing the event's payload" do 17 | class Subscriber 18 | include Omnes::Subscriber 19 | include Sidekiq::Job 20 | 21 | handle :create_foo, with: Adapter::Sidekiq 22 | 23 | def perform(payload) 24 | FOO_TABLE[payload["id"]] = payload["attributes"] 25 | end 26 | end 27 | FOO_TABLE = {} 28 | 29 | bus.register(:create_foo) 30 | Subscriber.new.subscribe_to(bus) 31 | event = Struct.new(:omnes_event_name, :payload).new(:create_foo, "id" => 1, "attributes" => { "name" => "foo" }) 32 | 33 | bus.publish(event) 34 | 35 | expect(FOO_TABLE[1]).to eq("name" => "foo") 36 | ensure 37 | Object.send(:remove_const, :Subscriber) 38 | Object.send(:remove_const, :FOO_TABLE) 39 | end 40 | 41 | it "can specify how to serialize the event" do 42 | class Subscriber 43 | include Omnes::Subscriber 44 | include Sidekiq::Job 45 | 46 | EVENT_SERIALIZER = lambda do |event| 47 | { 48 | "id" => event.id, 49 | "attributes" => { 50 | "name" => event.attributes[:name] 51 | } 52 | } 53 | end 54 | 55 | handle :create_foo, with: Adapter::Sidekiq[serializer: EVENT_SERIALIZER] 56 | 57 | def perform(payload) 58 | FOO_TABLE[payload["id"]] = payload["attributes"] 59 | end 60 | end 61 | event = Struct.new(:omnes_event_name, :id, :attributes).new(:create_foo, 1, { name: "foo" }) 62 | FOO_TABLE = {} 63 | 64 | bus.register(:create_foo) 65 | Subscriber.new.subscribe_to(bus) 66 | 67 | bus.publish(event) 68 | 69 | expect(FOO_TABLE[1]).to eq("name" => "foo") 70 | ensure 71 | Object.send(:remove_const, :Subscriber) 72 | Object.send(:remove_const, :FOO_TABLE) 73 | end 74 | 75 | it "can provide the serialized publication context" do 76 | class Subscriber 77 | include Omnes::Subscriber 78 | include Sidekiq::Job 79 | 80 | handle :foo, with: Adapter::Sidekiq 81 | 82 | def perform(_payload, publication_context) 83 | LOG[:publication_context] = publication_context 84 | end 85 | end 86 | LOG = {} 87 | 88 | bus.register(:foo) 89 | Subscriber.new.subscribe_to(bus) 90 | 91 | bus.publish(:foo) 92 | 93 | expect(LOG[:publication_context].is_a?(Hash)).to be(true) 94 | ensure 95 | Object.send(:remove_const, :Subscriber) 96 | Object.send(:remove_const, :LOG) 97 | end 98 | 99 | it "performs the job in given interval after the publication passing the event's payload" do 100 | class Subscriber 101 | include Sidekiq::Job 102 | include Omnes::Subscriber 103 | 104 | handle :create_foo, with: Adapter::Sidekiq.in(60) 105 | 106 | def perform(payload) 107 | FOO_TABLE[payload["id"]] = payload["attributes"] 108 | end 109 | end 110 | FOO_TABLE = {} 111 | 112 | bus.register(:create_foo) 113 | Subscriber.new.subscribe_to(bus) 114 | event = Struct.new(:omnes_event_name, :payload).new(:create_foo, "id" => 1, "attributes" => { "name" => "foo" }) 115 | 116 | expect(Subscriber).to receive(:perform_in).with(60, any_args).and_call_original 117 | 118 | bus.publish(event) 119 | 120 | expect(FOO_TABLE[1]).to eq("name" => "foo") 121 | ensure 122 | Object.send(:remove_const, :FOO_TABLE) 123 | Object.send(:remove_const, :Subscriber) 124 | end 125 | 126 | it "can provide the serialized publication context when giving an interval" do 127 | class Subscriber 128 | include Omnes::Subscriber 129 | include Sidekiq::Job 130 | 131 | handle :foo, with: Adapter::Sidekiq.in(60) 132 | 133 | def perform(_payload, publication_context) 134 | LOG[:publication_context] = publication_context 135 | end 136 | end 137 | LOG = {} 138 | 139 | bus.register(:foo) 140 | Subscriber.new.subscribe_to(bus) 141 | 142 | bus.publish(:foo) 143 | 144 | expect(LOG[:publication_context].is_a?(Hash)).to be(true) 145 | ensure 146 | Object.send(:remove_const, :Subscriber) 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /spec/unit/omnes/subscriber/adapter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "omnes/subscriber/adapter" 4 | 5 | RSpec.describe Omnes::Subscriber::Adapter do 6 | describe ".config" do 7 | it "nests Sidekiq.config under sidekiq" do 8 | expect( 9 | described_class.config.sidekiq 10 | ).to be(Omnes::Subscriber::Adapter::Sidekiq.config) 11 | end 12 | 13 | it "nests ActiveJob.config under active_job" do 14 | expect( 15 | described_class.config.active_job 16 | ).to be(Omnes::Subscriber::Adapter::ActiveJob.config) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/unit/omnes/subscriber_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "omnes/bus" 4 | require "omnes/subscriber" 5 | 6 | RSpec.describe Omnes::Subscriber do 7 | let(:subscriber_class) do 8 | Class.new do 9 | include Omnes::Subscriber 10 | 11 | attr_reader :called 12 | 13 | def initialize 14 | @called = false 15 | end 16 | end 17 | end 18 | let(:bus) { Omnes::Bus.new } 19 | 20 | describe ".config" do 21 | it "nests Adapter config under adapter" do 22 | expect( 23 | described_class.config.adapter 24 | ).to be(Omnes::Subscriber::Adapter.config) 25 | end 26 | end 27 | 28 | it "autodiscover is off by default" do 29 | bus.register(:foo) 30 | subscriber_class = Class.new do 31 | include Omnes::Subscriber 32 | 33 | def on_foo(_event) 34 | __method__ 35 | end 36 | end 37 | 38 | subscriber_class.new.subscribe_to(bus) 39 | 40 | expect(bus.subscriptions.empty?).to be(true) 41 | end 42 | 43 | describe ".[]" do 44 | it "can switch on autodiscovery" do 45 | bus.register(:foo) 46 | subscriber_class = Class.new do 47 | include Omnes::Subscriber[autodiscover: true] 48 | 49 | attr_reader :called 50 | 51 | def on_foo(_event) 52 | @called = true 53 | end 54 | end 55 | subscriber = subscriber_class.new 56 | 57 | subscriber.subscribe_to(bus) 58 | bus.publish(:foo) 59 | 60 | expect(subscriber.called).to be(true) 61 | end 62 | 63 | it "can specify custom strategy to autodiscover" do 64 | bus.register(:foo) 65 | subscriber_class = Class.new do 66 | include Omnes::Subscriber[ 67 | autodiscover: true, 68 | autodiscover_strategy: ->(event_name) { :"left_#{event_name}_right" } 69 | ] 70 | 71 | attr_reader :called 72 | 73 | def inititalize 74 | @called = false 75 | end 76 | 77 | def left_foo_right(_event) 78 | @called = true 79 | end 80 | end 81 | subscriber = subscriber_class.new 82 | 83 | subscriber.subscribe_to(bus) 84 | bus.publish(:foo) 85 | 86 | expect(subscriber.called).to be(true) 87 | end 88 | end 89 | 90 | describe ".handle" do 91 | it "subscribes to the event matching given name" do 92 | bus.register(:foo) 93 | subscriber_class.class_eval do 94 | handle :foo, with: :foo 95 | 96 | def foo(_event) 97 | @called = true 98 | end 99 | end 100 | subscriber = subscriber_class.new 101 | 102 | subscriber.subscribe_to(bus) 103 | bus.publish(:foo) 104 | 105 | expect(subscriber.called).to be(true) 106 | end 107 | 108 | it "can provide id for the subscription" do 109 | bus.register(:foo) 110 | subscriber_class.class_eval do 111 | handle :foo, with: :foo, id: :foo 112 | 113 | def foo(_event); end 114 | end 115 | subscriber = subscriber_class.new 116 | 117 | subscriptions = subscriber.subscribe_to(bus) 118 | 119 | expect(bus.subscription(:foo)).to be(subscriptions[0]) 120 | end 121 | 122 | it "can provide instance-based id for the subscription" do 123 | bus.register(:foo) 124 | subscriber_class = Class.new do 125 | include Omnes::Subscriber 126 | 127 | attr_reader :id_suffix 128 | 129 | def initialize(id_suffix) 130 | @id_suffix = id_suffix 131 | end 132 | 133 | handle :foo, with: :foo, id: ->(instance) { :"foo_#{instance.id_suffix}" } 134 | 135 | def foo(_event); end 136 | end 137 | subscriber = subscriber_class.new(:one) 138 | 139 | subscriptions = subscriber.subscribe_to(bus) 140 | 141 | expect(bus.subscription(:foo_one)).to be(subscriptions[0]) 142 | end 143 | 144 | it "raises when given subscription id has already been used" do 145 | bus.register(:foo) 146 | bus.register(:bar) 147 | subscriber_class.class_eval do 148 | handle :foo, with: :foo, id: :foo 149 | handle :bar, with: :foo, id: :foo 150 | 151 | def foo(_event); end 152 | end 153 | subscriber = subscriber_class.new 154 | 155 | expect { 156 | subscriber.subscribe_to(bus) 157 | }.to raise_error(Omnes::DuplicateSubscriptionIdError) 158 | end 159 | 160 | it "doesn't subscribe to other events" do 161 | bus.register(:foo) 162 | bus.register(:bar) 163 | subscriber_class.class_eval do 164 | handle :foo, with: :foo 165 | 166 | def foo(_event) 167 | @called = true 168 | end 169 | end 170 | subscriber = subscriber_class.new 171 | 172 | subscriber.subscribe_to(bus) 173 | bus.publish(:bar) 174 | 175 | expect(subscriber.called).to be(false) 176 | end 177 | 178 | it "builds the callback from a matching method when given a symbol" do 179 | bus.register(:foo) 180 | subscriber_class.class_eval do 181 | handle :foo, with: :foo 182 | 183 | def foo(_event) 184 | @called = true 185 | end 186 | end 187 | subscriber = subscriber_class.new 188 | 189 | subscriber.subscribe_to(bus) 190 | bus.publish(:foo) 191 | 192 | expect(subscriber.called).to be(true) 193 | end 194 | 195 | it "builds the callback from given lambda" do 196 | bus.register(:foo) 197 | subscriber_class.class_eval do 198 | handle :foo, with: ->(instance, event) { instance.method(:bar).(event) } 199 | 200 | def bar(_event) 201 | @called = true 202 | end 203 | end 204 | subscriber = subscriber_class.new 205 | 206 | subscriber.subscribe_to(bus) 207 | bus.publish(:foo) 208 | 209 | expect(subscriber.called).to be(true) 210 | end 211 | 212 | it "raises when trying to subscribe to an unregistered event" do 213 | subscriber_class.class_eval do 214 | handle :foo, with: :foo 215 | 216 | def foo(_event); end 217 | end 218 | 219 | expect { 220 | subscriber_class.new.subscribe_to(bus) 221 | }.to raise_error( 222 | Omnes::UnknownEventError 223 | ) 224 | end 225 | end 226 | 227 | describe ".handle_all" do 228 | it "subscribes to all events" do 229 | bus.register(:foo) 230 | subscriber_class.class_eval do 231 | handle_all with: :foo 232 | 233 | def foo(_event) 234 | @called = true 235 | end 236 | end 237 | subscriber = subscriber_class.new 238 | 239 | subscriber.subscribe_to(bus) 240 | bus.publish(:foo) 241 | 242 | expect(subscriber.called).to be(true) 243 | end 244 | 245 | it "can provide id for the subscription" do 246 | bus.register(:foo) 247 | subscriber_class.class_eval do 248 | handle_all with: :foo, id: :all 249 | 250 | def foo(_event); end 251 | end 252 | subscriber = subscriber_class.new 253 | 254 | subscriptions = subscriber.subscribe_to(bus) 255 | 256 | expect(bus.subscription(:all)).to be(subscriptions[0]) 257 | end 258 | 259 | it "can provide instance-based id for the subscription" do 260 | subscriber_class = Class.new do 261 | include Omnes::Subscriber 262 | 263 | attr_reader :id_suffix 264 | 265 | def initialize(id_suffix) 266 | @id_suffix = id_suffix 267 | end 268 | 269 | handle_all with: :foo, id: ->(instance) { :"foo_#{instance.id_suffix}" } 270 | 271 | def foo(_event); end 272 | end 273 | subscriber = subscriber_class.new(:one) 274 | 275 | subscriptions = subscriber.subscribe_to(bus) 276 | 277 | expect(bus.subscription(:foo_one)).to be(subscriptions[0]) 278 | end 279 | 280 | it "raises when given subscription id has already been used" do 281 | subscriber_class.class_eval do 282 | handle_all with: :foo, id: :foo 283 | handle_all with: :foo, id: :foo 284 | 285 | def foo(_event); end 286 | end 287 | subscriber = subscriber_class.new 288 | 289 | expect { 290 | subscriber.subscribe_to(bus) 291 | }.to raise_error(Omnes::DuplicateSubscriptionIdError) 292 | end 293 | 294 | it "builds the callback from a matching method when given a symbol" do 295 | bus.register(:foo) 296 | subscriber_class.class_eval do 297 | handle_all with: :foo 298 | 299 | def foo(_event) 300 | @called = true 301 | end 302 | end 303 | subscriber = subscriber_class.new 304 | 305 | subscriber.subscribe_to(bus) 306 | bus.publish(:foo) 307 | 308 | expect(subscriber.called).to be(true) 309 | end 310 | 311 | it "builds the callback from given lambda" do 312 | bus.register(:foo) 313 | subscriber_class.class_eval do 314 | handle_all with: ->(instance, event) { instance.method(:bar).(event) } 315 | 316 | def bar(_event) 317 | @called = true 318 | end 319 | end 320 | subscriber = subscriber_class.new 321 | 322 | subscriber.subscribe_to(bus) 323 | bus.publish(:foo) 324 | 325 | expect(subscriber.called).to be(true) 326 | end 327 | end 328 | 329 | describe ".handle_with_matcher" do 330 | it "subscribes to events matching with given matcher" do 331 | bus.register(:foo) 332 | subscriber_class.class_eval do 333 | TRUE_MATCHER = ->(_candidate) { true } 334 | 335 | handle_with_matcher TRUE_MATCHER, with: :foo 336 | 337 | def foo(_event) 338 | @called = true 339 | end 340 | end 341 | subscriber = subscriber_class.new 342 | 343 | subscriber.subscribe_to(bus) 344 | bus.publish(:foo) 345 | 346 | expect(subscriber.called).to be(true) 347 | ensure 348 | Object.send(:remove_const, :TRUE_MATCHER) 349 | end 350 | 351 | it "can provide id for the subscription" do 352 | bus.register(:foo) 353 | subscriber_class.class_eval do 354 | TRUE_MATCHER = ->(_candidate) { true } 355 | 356 | handle_with_matcher TRUE_MATCHER, with: :foo, id: :foo 357 | 358 | def foo(_event); end 359 | end 360 | subscriber = subscriber_class.new 361 | 362 | subscriptions = subscriber.subscribe_to(bus) 363 | 364 | expect(bus.subscription(:foo)).to be(subscriptions[0]) 365 | ensure 366 | Object.send(:remove_const, :TRUE_MATCHER) 367 | end 368 | 369 | it "can provide instance-based id for the subscription" do 370 | subscriber_class = Class.new do 371 | include Omnes::Subscriber 372 | 373 | TRUE_MATCHER = ->(_candidate) { true } 374 | 375 | attr_reader :id_suffix 376 | 377 | def initialize(id_suffix) 378 | @id_suffix = id_suffix 379 | end 380 | 381 | handle_with_matcher TRUE_MATCHER, with: :foo, id: ->(instance) { :"foo_#{instance.id_suffix}" } 382 | 383 | def foo(_event); end 384 | end 385 | subscriber = subscriber_class.new(:one) 386 | 387 | subscriptions = subscriber.subscribe_to(bus) 388 | 389 | expect(bus.subscription(:foo_one)).to be(subscriptions[0]) 390 | ensure 391 | Object.send(:remove_const, :TRUE_MATCHER) 392 | end 393 | 394 | it "raises when given subscription id has already been used" do 395 | subscriber_class.class_eval do 396 | TRUE_MATCHER = ->(_candidate) { true } 397 | 398 | handle_with_matcher TRUE_MATCHER, with: :foo, id: :foo 399 | handle_with_matcher TRUE_MATCHER, with: :foo, id: :foo 400 | 401 | def foo(_event); end 402 | end 403 | subscriber = subscriber_class.new 404 | 405 | expect { 406 | subscriber.subscribe_to(bus) 407 | }.to raise_error(Omnes::DuplicateSubscriptionIdError) 408 | ensure 409 | Object.send(:remove_const, :TRUE_MATCHER) 410 | end 411 | 412 | it "builds the callback from a matching method when given a symbol" do 413 | bus.register(:foo) 414 | subscriber_class.class_eval do 415 | TRUE_MATCHER = ->(_candidate) { true } 416 | 417 | handle_with_matcher TRUE_MATCHER, with: :foo 418 | 419 | def foo(_event) 420 | @called = true 421 | end 422 | end 423 | subscriber = subscriber_class.new 424 | 425 | subscriber.subscribe_to(bus) 426 | bus.publish(:foo) 427 | 428 | expect(subscriber.called).to be(true) 429 | ensure 430 | Object.send(:remove_const, :TRUE_MATCHER) 431 | end 432 | 433 | it "builds the callback from given lambda" do 434 | bus.register(:foo) 435 | subscriber_class.class_eval do 436 | TRUE_MATCHER = ->(_candidate) { true } 437 | 438 | handle_with_matcher TRUE_MATCHER, with: ->(instance, event) { instance.method(:bar).(event) } 439 | 440 | def bar(_event) 441 | @called = true 442 | end 443 | end 444 | subscriber = subscriber_class.new 445 | 446 | subscriber.subscribe_to(bus) 447 | bus.publish(:foo) 448 | 449 | expect(subscriber.called).to be(true) 450 | ensure 451 | Object.send(:remove_const, :TRUE_MATCHER) 452 | end 453 | end 454 | 455 | describe "#subscribe_to" do 456 | it "can subscribe multiple instances to the same bus" do 457 | bus.register(:foo) 458 | subscriber_class.class_eval do 459 | handle :foo, with: :foo, id: ->(instance) { instance.object_id.to_s.to_sym } 460 | 461 | def foo(_event) 462 | @called = true 463 | end 464 | end 465 | subscriber1 = subscriber_class.new 466 | subscriber2 = subscriber_class.new 467 | 468 | subscriber1.subscribe_to(bus) 469 | subscriber2.subscribe_to(bus) 470 | bus.publish(:foo) 471 | 472 | expect(subscriber1.called).to be(true) 473 | expect(subscriber2.called).to be(true) 474 | end 475 | 476 | it "can subscribe multiple instances to the same bus with autodiscovery" do 477 | bus.register(:foo) 478 | subscriber_class = Class.new do 479 | include Omnes::Subscriber[autodiscover: true] 480 | 481 | attr_reader :called 482 | 483 | def initialize 484 | @called = false 485 | end 486 | 487 | def on_foo(_event) 488 | @called = true 489 | end 490 | end 491 | subscriber1 = subscriber_class.new 492 | subscriber2 = subscriber_class.new 493 | 494 | subscriber1.subscribe_to(bus) 495 | subscriber2.subscribe_to(bus) 496 | bus.publish(:foo) 497 | 498 | expect(subscriber1.called).to be(true) 499 | expect(subscriber2.called).to be(true) 500 | end 501 | 502 | it "doesn't readd previous autodiscoveries when subscribing a second time" do 503 | bus.register(:foo) 504 | subscriber_class = Class.new do 505 | include Omnes::Subscriber[autodiscover: true] 506 | 507 | def on_foo(_event); end 508 | end 509 | subscriber1 = subscriber_class.new 510 | subscriber2 = subscriber_class.new 511 | 512 | subscriber1.subscribe_to(bus) 513 | subscriber2.subscribe_to(bus) 514 | 515 | expect(bus.subscriptions.count).to be(2) 516 | end 517 | 518 | it "can subscribe the same instance to different buses" do 519 | bus_one = Omnes::Bus.new 520 | bus_two = Omnes::Bus.new 521 | [bus_one, bus_two].each { |bus| bus.register(:foo) } 522 | subscriber_class.class_eval do 523 | handle :foo, with: :foo 524 | 525 | def foo(_event); end 526 | end 527 | subscriber = subscriber_class.new 528 | 529 | subscriber.subscribe_to(bus_one) 530 | subscriber.subscribe_to(bus_two) 531 | 532 | expect(bus_one.subscriptions.count).to be(1) 533 | expect(bus_two.subscriptions.count).to be(1) 534 | end 535 | 536 | it "doesn't add any subscription if there's an error" do 537 | bus.register(:foo) 538 | bus.register(:bar) 539 | subscriber_class.class_eval do 540 | handle :foo, with: :foo 541 | handle :bar, with: :bar 542 | 543 | def foo(_event); end 544 | end 545 | 546 | expect { 547 | subscriber_class.new.subscribe_to(bus) 548 | }.to raise_error(described_class::Adapter::Method::UnknownMethodSubscriptionAttemptError) 549 | 550 | expect(bus.subscriptions.count).to be(0) 551 | end 552 | 553 | it "raises when calling the same instance multiple times for the same bus" do 554 | bus.register(:foo) 555 | subscriber_class.class_eval do 556 | handle :foo, with: :foo 557 | 558 | def foo(_event); end 559 | end 560 | subscriber = subscriber_class.new 561 | 562 | subscriber.subscribe_to(bus) 563 | 564 | expect { 565 | subscriber.subscribe_to(bus) 566 | }.to raise_error(described_class::MultipleSubscriberSubscriptionAttemptError) 567 | end 568 | 569 | it "accepts the adapter as a two args callable" do 570 | bus.register(:foo) 571 | subscriber_class.class_eval do 572 | handle :foo, with: ->(instance, event) { instance.method(:bar).(event) } 573 | 574 | def bar(_event) 575 | @called = true 576 | end 577 | end 578 | subscriber = subscriber_class.new 579 | 580 | subscriber.subscribe_to(bus) 581 | bus.publish(:foo) 582 | 583 | expect(subscriber.called).to be(true) 584 | end 585 | 586 | it "accepts the adapter as a one arg callable" do 587 | bus.register(:foo) 588 | subscriber_class.class_eval do 589 | handle :foo, with: ->(instance) { ->(event) { instance.method(:bar).(event) } } 590 | 591 | def bar(_event) 592 | @called = true 593 | end 594 | end 595 | subscriber = subscriber_class.new 596 | 597 | subscriber.subscribe_to(bus) 598 | bus.publish(:foo) 599 | 600 | expect(subscriber.called).to be(true) 601 | end 602 | end 603 | end 604 | -------------------------------------------------------------------------------- /spec/unit/omnes/subscription_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "omnes/subscription" 5 | 6 | RSpec.describe Omnes::Subscription do 7 | let(:true_matcher) { ->(_candidate) { true } } 8 | let(:false_matcher) { ->(_candidate) { false } } 9 | 10 | describe "SINGLE_EVENT_MATCHER" do 11 | it "returns true when candidate name matches subscribed" do 12 | event = Struct.new(:omnes_event_name).new(:foo) 13 | 14 | expect( 15 | described_class::SINGLE_EVENT_MATCHER.(:foo, event) 16 | ).to be(true) 17 | end 18 | 19 | it "returns false when published and candidate don't match" do 20 | event = Struct.new(:omnes_event_name).new(:foo) 21 | 22 | expect( 23 | described_class::SINGLE_EVENT_MATCHER.(:bar, event) 24 | ).to be(false) 25 | end 26 | end 27 | 28 | describe "ALL_EVENTS_MATCHER" do 29 | it "returns true whichever the candidate" do 30 | expect( 31 | described_class::ALL_EVENTS_MATCHER.(:foo) 32 | ).to be(true) 33 | end 34 | end 35 | 36 | describe "#initialize" do 37 | it "raises when id is not a Symbol" do 38 | expect { 39 | described_class.new(matcher: true_matcher, callback: proc {}, id: 1) 40 | }.to raise_error(Omnes::InvalidSubscriptionNameError) 41 | end 42 | end 43 | 44 | describe "#call" do 45 | it "binds the event as the first subscription parameter" do 46 | callback = ->(event) { expect(event).to be(:event) } 47 | 48 | subscription = described_class.new(matcher: true_matcher, callback: callback, id: :id) 49 | 50 | subscription.(:event, :context) 51 | end 52 | 53 | it "binds the publication context when subscription accepts a second argument" do 54 | callback = ->(_event, context) { expect(context).to be(:context) } 55 | 56 | subscription = described_class.new(matcher: true_matcher, callback: callback, id: :id) 57 | 58 | subscription.(:event, :context) 59 | end 60 | 61 | it "returns an execution instance" do 62 | subscription = described_class.new(matcher: true_matcher, callback: proc {}, id: :id) 63 | 64 | expect(subscription.(:event, :context)).to be_a(Omnes::Execution) 65 | end 66 | 67 | it "sets the execution result" do 68 | callback = ->(event) { event } 69 | 70 | subscription = described_class.new(matcher: true_matcher, callback: callback, id: :id) 71 | 72 | execution = subscription.(:event, :context) 73 | 74 | expect(execution.result).to be(:event) 75 | end 76 | 77 | it "sets itself as the execution subscription" do 78 | subscription = described_class.new(matcher: true_matcher, callback: proc { "foo" }, id: :id) 79 | 80 | execution = subscription.(:event, :context) 81 | 82 | expect(execution.subscription).to be(subscription) 83 | end 84 | 85 | it "sets the execution's benchmark" do 86 | subscription = described_class.new(matcher: true_matcher, callback: proc { "foo" }, id: :id) 87 | 88 | execution = subscription.(:event, :context) 89 | 90 | expect(execution.benchmark).to be_a(Benchmark::Tms) 91 | end 92 | end 93 | 94 | describe "#matches?" do 95 | it "return true when matcher returns true for given candidate" do 96 | subscription = described_class.new(matcher: true_matcher, callback: -> {}, id: :id) 97 | 98 | expect(subscription.matches?(:foo)).to be(true) 99 | end 100 | 101 | it "return false when matcher returns false for given candidate" do 102 | subscription = described_class.new(matcher: false_matcher, callback: -> {}, id: :id) 103 | 104 | expect(subscription.matches?(:bar)).to be(false) 105 | end 106 | end 107 | 108 | describe "#subscriptions" do 109 | it "returns a list containing only itself" do 110 | subscription = described_class.new(matcher: true_matcher, callback: -> {}, id: :id) 111 | 112 | expect(subscription.subscriptions).to eq([subscription]) 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /spec/unit/omnes/unstructured_event_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Omnes::UnstructuredEvent do 6 | describe "#[]" do 7 | it "accesses payload" do 8 | event = described_class.new(payload: { foo: :bar }, omnes_event_name: :foo) 9 | 10 | expect(event[:foo]).to be(:bar) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/unit/omnes_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "omnes" 5 | require "support/shared_examples/bus" 6 | 7 | RSpec.describe Omnes do 8 | subject { Class.new.include(described_class) } 9 | 10 | include_examples "bus" 11 | 12 | it "doesn't share buses between instances" do 13 | klass = Class.new.include(described_class) 14 | 15 | expect(klass.new.omnes_bus).not_to be(klass.new.omnes_bus) 16 | end 17 | 18 | describe ".config" do 19 | it "nests Omnes::Event config under event" do 20 | expect( 21 | described_class.config.event 22 | ).to be(Omnes::Event.config) 23 | end 24 | 25 | it "nests Omnes::Subscriber config under subscriber" do 26 | expect( 27 | described_class.config.subscriber 28 | ).to be(Omnes::Subscriber.config) 29 | end 30 | end 31 | end 32 | --------------------------------------------------------------------------------