This gem provides a straightforward way to implement communication between Rails Engines using the Publish-Subscribe pattern. The gem allows decreasing the coupling of engines with events. An event is a recorded object in the system that reflects an action that the engine performs, and the params that lead to its creation.
The gem inspired by active_event_store, and initially based on its codebase. Having said that, it does not store in a database all happened events which ensures simplicity and performance.
Add this line to your application's Gemfile:
gem "downstream", "~> 1.0"Downstream provides a way more handy interface to build reactive apps. Each event has a strict schema described by a separate class. The gem has convenient tooling to write tests.
Downstream supports various adapters for event handling. It can be configured in a Rails initializer config/initializers/downstream.rb:
Downstream.configure do |config|
config.pubsub = :stateless # it's a default adapter
config.async_queue = :high_priority # nil by default
endFor now, it's implemented only one adapter. The stateless adapter is based on ActiveSupport::Notifications, and it doesn't store history events anywhere. All event invocations are synchronous. Adding asynchronous subscribers are on my road map.
Events are represented by event classes, which describe events payloads and identifiers:
class ProfileCreated < Downstream::Event
# (optional)
# Event identifier is used for streaming events to subscribers.
# By default, identifier is equal to underscored class name.
# You don't need to specify identifier manually, only for backward compatibility when
# class name is changed.
self.identifier = "profile_created"
# Add attributes accessors
attributes :user
endEach event has predefined (reserved) fields:
event_id– unique event idtype– event type (=identifier)
NOTE: events should be in the past tense and describe what happened (e.g. "ProfileCreated", "EventPublished", etc.).
Events are stored in app/events folder.
You can also define events using the Data-interface:
ProfileCreated = Downstream::Event.define(:user)
# or with an explicit identifier
ProfileCreated = Downstream::Event.define(:user) do
self.identifier = "user.profile_created"
endDate-events provide the same interface as regular events but use Data classes for keeping event payloads (event.data) and are frozen (as well as their derivatives, such as event.to_h).
Note
Data-events are only available in Ruby 3.2+.
To publish an event you must first create an instance of the event class and call Downstream.publish method:
event = ProfileCompleted.new(user: user)
# then publish the event
Downstream.publish(event)That's it! Your event has been stored and propagated.
To subscribe a handler to an event you must use Downstream.subscribe method.
You should do this in your app or engine initializer:
# some/engine.rb
initializer "my_engine.subscribe_to_events" do
# To make sure event store is initialized use load hook
# `store` == `Downstream`
ActiveSupport.on_load "downstream-events" do |store|
store.subscribe MyEventHandler, to: ProfileCreated
# anonymous handler (could only be synchronous)
store.subscribe(to: ProfileCreated) do |event|
# do something
end
# you can omit event if your subscriber follows the convention
# for example, the following subscriber would subscribe to
# ProfileCreated event
store.subscribe OnProfileCreated::DoThat
end
endNOTE: event handler must be a callable object.
Although subscriber could be any callable Ruby object, that have specific input format (event); thus we suggest putting subscribers under app/subscribers/on_<event_type>/<subscriber.rb>, e.g. app/subscribers/on_profile_created/create_chat_user.rb).
Sometimes, you may be interested in using temporary subscriptions. For that, you can use this:
subscriber = ->(event) { my_event_handler(event) }
Downstream.subscribed(subscriber, to: ProfileCreated) do
some_invocation
endIf you want to handle events in a background job, you can pass the async: true option:
store.subscribe OnProfileCreated::DoThat, async: trueBy default, a job will be enqueued into async_queue name from the Downstream config. You can define your own queue name for a specific subscriber:
store.subscribe OnProfileCreated::DoThat, async: {queue: :low_priority}NOTE: all subscribers are synchronous by default
You can also use subscriber objects based on Downstream::Subscriber class. For example:
class CRMSubscriber < Downstream::Subscriber
def profile_created(event)
# handle "profile_created" event
end
def project_created(event)
# handle "project_created" event
end
end
store.subscribe CRMSubscriber, async: trueThe subscriber object allows you to subscribe to multiple events at once: each public method is considered an event handler for the same named event.
You can test subscribers as normal Ruby objects.
First, load testing helpers in the spec_helper.rb:
require "downstream/rspec"To test that a given subscriber exists, you can do the following:
it "is subscribed to some event" do
allow(MySubscriberService).to receive(:call)
event = MyEvent.new(some: "data")
Downstream.publish event
expect(MySubscriberService).to have_received(:call).with(event)
end
# for asynchronous subscriptions
it "is subscribed to some event" do
event = MyEvent.new(some: "data")
expect { Downstream.publish event }
.to have_enqueued_async_subscriber_for(MySubscriberService)
.with(event)
endTo test publishing use have_published_event matcher:
expect { subject }.to have_published_event(ProfileCreated).with(user: user)NOTE: have_published_event only supports block expectations.
NOTE 2 with modifier works like have_attributes matcher (not contain_exactly);