This article has been partly inspired by an upcoming chapter of the author's Advanced CableReady book, and tailored to fit this guest post for AppSignal.
Notifications are a typical cross-cutting concern shared by many web applications.
The Noticed gem makes developing notifications fantastically easy by providing a database-backed model and pluggable delivery methods for your Ruby on Rails application. It comes with built-in support for mailers, websockets, and a couple of other delivery methods.
We'll also examine the merits of using the CableReady gem for triggering system notifications in your Ruby on Rails application.
Let's get into it!
Prerequisites and Requirements
Every now and then, you might need to trigger system notifications from your app. You can achieve this with the Notifications API.
For example, let's say your application allows users to upload large files which take a long time to be transcoded. You may want to send users a notification once their video upload completes. This means they can switch to a new task in the meantime and don't have to keep your application open for several minutes.
Luckily, it's easy to implement and flesh out system notifications with these two prerequisites:
- Noticed has support for custom delivery methods (Noticed exposes a simple API to implement any transport mechanism you like — for example, posting to Discord servers).
- CableReady has a notification operation arrow in its quiver.
The list of requirements for CableReady and Noticed is short — you simply need the following:
- an ActionCable server running
- an ActiveJob backend (typically used by Noticed)
Note: The entire sample code for this project is available on GitHub. You can also follow along below — we will guide you step-by-step.
A CableReady Primer for your Ruby on Rails Application
Let's start by making ourselves familiar with CableReady. Released in 2017, it can be thought of as the "missing ActionCable standard library".
Before the advent of Turbo, the only way to craft real-time applications in Ruby on Rails was through ActionCable. ActionCable is the native Rails wrapper around WebSockets, providing both a server-side and client-side API to send messages through a persistent connection in both directions at any time.
The downside to this approach was (and is) that you have to write a lot of boilerplate code to make it happen.
This is where CableReady helps by providing an abstraction layer around a multitude of DOM operations to be triggered on the server. A few examples include:
- DOM mutations (
inner_html
,insert_adjacent_html
,morph
, etc.) - DOM element property mutations (
add_css_class
,remove_css_class
,set_dataset_property
, etc.) - dispatching arbitrary DOM events
- browser history manipulations
- notifications (which we will make use of)
The CableReady Server and Client
How does CableReady work its magic? In a nutshell, it consists of a server-side and client-side part.
On the server side, the module CableReady::Broadcaster
can be included in any part of your application that calls for it. This could be in a job, a model callback, or a plain controller. But first, an ActionCable channel has to be in place. To cite CableReady's official documentation:
class ExampleChannel < ApplicationCable::Channel
def subscribed
stream_from "visitors"
end
end
Note that visitors
is called a stream identifier. It can be used to target either a broad audience or only specific clients subscribed to your channel. To conclude the example, we can include the broadcaster module in a model and send a console.log
to the client after sign-up:
class User < ApplicationRecord
include CableReady::Broadcaster
after_create do
cable_ready["visitors"].console_log(message: "Welcome #{self.name} to the site!")
cable_ready.broadcast # send queued console_log operation to all ExampleChannel subscribers
end
end
On the client side, the logic is simple. Create a subscription to the aforementioned channel. Then, in its received hook, call CableReady.perform
on all operations passed over the wire:
import CableReady from "cable_ready";
import consumer from "./consumer";
consumer.subscriptions.create("ExampleChannel", {
received(data) {
if (data.cableReady) CableReady.perform(data.operations);
},
});
CableReady vs. Turbo for Rails
Summing up, when should you use CableReady, and when should you avoid it?
With the introduction of Turbo, the web development community received a powerful toolbox to craft server-rendered reactive applications. Being essentially a frontend technology with powerful server-side bindings for Rails, it fits well into the standard Model-View-Controller (MVC) stack. Thus, it can cover most of your typical app's requirements.
CableReady, on the other hand, is the Swiss army knife of real-time Rails development and should be used with care. It is a powerful abstraction that can seem very inviting to use pervasively. But if you imagine that every part of your DOM can be mutated from any location in your app, you'll understand that this can lead to race conditions and hard-to-track-down bugs.
There are cases like the one at hand, though, where CableReady makes perfect sense because it allows for more fine-grained control over the DOM.
Asked for a simple TLDR, I would respond that Turbo is for application developers, while CableReady is for library builders. But as we will see, there are gray areas between the two.
Noticed — Simple Notifications for Ruby on Rails Applications
The second library we will apply to deliver system notifications is Chris Oliver's Noticed gem. At its heart, it is built upon an ActiveRecord model that models a single notification to a recipient. It holds common metadata, such as:
- who a notification was sent to (the recipient)
- when the notification was read
- any parameters the notification is associated with (typically a reference to another model)
If you are familiar with how the ActiveStorage/ActionText meta-tables work, this is very similar.
Adjacent to this, Noticed employs POROs (Plain Old Ruby Objects, i.e., objects without any connection to Rails or other frameworks), which serve as blueprints for actual notifications. These are, somewhat misleadingly, also called Notifications and carry logic about how to render and distribute them. Here is an example from the README:
class CommentNotification < Noticed::Base
deliver_by :database
deliver_by :action_cable
deliver_by :email, mailer: 'CommentMailer', if: :email_notifications?
# I18n helpers
def message
t(".message")
end
# URL helpers are accessible in notifications
# Don't forget to set your default_url_options so Rails knows how to generate urls
def url
post_path(params[:post])
end
def email_notifications?
!!recipient.preferences[:email]
end
end
We will see this at work shortly. Of special interest are the deliver_by
invocations, as they determine which delivery methods this notification should use:
-
deliver_by :database
stores a Notification record (of the model mentioned above) for later access -
deliver_by :action_cable
sends it via a defined ActionCable channel and stream (defaultNoticed::NotificationChannel
) -
deliver_by :email
specifies a mailer used to send the notification. The example displays how to factor in any preferences the recipient(s) might have set.
Our goal for the remainder of this article is to implement a custom delivery method that will send our system notifications.
A Custom Delivery Method: System Notifications via the Notifications API
Before we set out to do this, let's clear the ground by creating a new Rails application. Because integrating with CableReady is easier this way, I opted for the esbuild
JavaScript option over importmaps
:
$ rails new noticed-cableready --javascript=esbuild
Note: At the time of writing, the current Rails version is 7.0.4.
If you have an existing Rails application, you can skip the next step, but make sure you have a User model or similar concept to act as a notification recipient.
1. Prepare Recipients
Noticed needs a User model to act as recipients, so to be concise, pull in Devise and generate a User model.
Afterward, open the Rails console and create a sample user:
$ bundle add devise
$ bin/rails generate devise:install
$ bin/rails generate devise User
$ bin/rails db:migrate
$ bin/rails c
irb(main):001:1* User.create(
irb(main):002:1* email: "julian@example.com",
irb(main):003:1* password: "mypassword",
irb(main):004:1* password_confirmation: "mypassword"
irb(main):005:1> )
2. Add Noticed
Next, let's add Noticed to our bundle and generate the database model.
$ bundle add noticed
$ bin/rails generate noticed:model
generate model
rails generate model Notification recipient:references{polymorphic} type params:json read_at:datetime:index
invoke active_record
create db/migrate/20221026184101_create_notifications.rb
create app/models/notification.rb
invoke test_unit
create test/models/notification_test.rb
create test/fixtures/notifications.yml
insert app/models/notification.rb
insert db/migrate/20221026184101_create_notifications.rb
🚚 Your notifications database model has been generated!
Next steps:
1. Run "rails db:migrate"
2. Add "has_many :notifications, as: :recipient, dependent: :destroy" to your User model(s).
3. Generate notifications with "rails g noticed:notification"
We do as told, run db:migrate
and add the polymorphic has_many
association to our User model:
# app/models/user.rb
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
has_many :notifications, as: :recipient, dependent: :destroy
end
The final piece before we can put this to the test is to create a blueprint PORO:
$ bin/rails generate noticed:notification TestNotification
# app/notifications/test_notification.rb
class TestNotification < Noticed::Base
deliver_by :database
def message
"A system notification"
end
end
We tweak it a bit so that it just uses the database delivery method and a placeholder message for the moment. You would typically add required params here, like a model id, to construct the message and the URL to link to — see the Noticed README for details.
Using only the Rails console, we can demonstrate how to deliver a notification based on this PORO now:
$ bin/rails c
irb(main):001:0> TestNotification.deliver(User.first)
User Load (0.3ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
Performing Noticed::DeliveryMethods::Database (Job ID: 32f27ac7-fb2e-42e4-9c5e-a3290d2d1297) from Async(default) enqueued at with arguments: {:notification_class=>"TestNotification", :options=>{}, :params=>{}, :recipient=>#<GlobalID:0x00000001111e1718 @uri=#<URI::GID gid://noticed-cableready/User/1>>, :record=>nil}
TRANSACTION (0.1ms) begin transaction
Notification Create (0.6ms) INSERT INTO "notifications" ("recipient_type", "recipient_id", "type", "params", "read_at", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?, ?, ?) [["recipient_type", "User"], ["recipient_id", 1], ["type", "TestNotification"], ["params", "{\"_aj_symbol_keys\":[]}"], ["read_at", nil], ["created_at", "2022-10-27 07:25:28.865982"], ["updated_at", "2022-10-27 07:25:28.865982"]]
TRANSACTION (0.3ms) commit transaction
Performed Noticed::DeliveryMethods::Database (Job ID: 32f27ac7-fb2e-42e4-9c5e-a3290d2d1297) from Async(default) in 29.93ms
=> [#<User id: 1, email: "julian@example.com", created_at: "2022-10-26 18:51:40.370635000 +0000", updated_at: "2022-10-26 18:51:40.370635000 +0000">]
As we can see, Noticed performs a database insert, so now we can grab all Notifications
for a specified user:
irb(main):002:0> User.first.notifications
User Load (0.6ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
Notification Load (0.8ms) SELECT "notifications".* FROM "notifications" WHERE "notifications"."recipient_type" = ? AND "notifications"."recipient_id" = ? [["recipient_type", "User"], ["recipient_id", 1]]
=>
[#<Notification:0x00000001113e3b38
id: 1,
recipient_type: "User",
recipient_id: 1,
type: "TestNotification",
params: {},
read_at: nil,
created_at: Thu, 27 Oct 2022 07:25:28.865982000 UTC +00:00,
updated_at: Thu, 27 Oct 2022 07:25:28.865982000 UTC +00:00>]
This is enough for us to construct a simple index view that lists all delivered notifications for the current user:
$ bin/rails g controller Notifications index
# app/controllers/notifications_controller.rb
class NotificationsController < ApplicationController
def index
@notifications = current_user.notifications
end
end
<!-- app/views/notifications/index.html.erb -->
<h1>Notifications</h1>
<ul>
<% @notifications.each do |notification| %>
<% instance = notification.to_notification %>
<li>
<p>Sent at: <%= notification&.created_at.to_s %></p>
<p>Message: <%= instance.message %></p>
</li>
<% end %>
</ul>
After spinning up the app with bin/dev
, we can log in and browse to http://localhost:3000/notifications:
3. Installing CableReady
To make use of CableReady, we need to install it. Luckily, this is quickly done:
$ bundle add cable_ready
$ yarn add cable_ready@4.5.0
Let's generate a NotificationChannel
to deliver our messages:
$ bin/rails g channel Notification
This will add all the missing ActionCable (JavaScript) dependencies and scaffold the respective channel files, specifically app/channels/notification_channel.rb
and app/javascript/channels/notification_channel.js
.
For the server-side channel, we inherit from Noticed::NotificationChannel
:
# app/channels/notification_channel.rb
class NotificationChannel < Noticed::NotificationChannel
end
Before we continue, we need to ensure our ActionCable is authenticated for Devise users. I won't go into details about that here. The necessary boilerplate looks like this:
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
protected
def find_verified_user
if (current_user = env["warden"].user)
current_user
else
reject_unauthorized_connection
end
end
end
end
Check out the StimulusReflex docs for other options.
On the client side, we need to add the tiny bit of setup code mentioned above:
// app/javascript/channels/notification_channel.js
import CableReady from "cable_ready";
import consumer from "./consumer";
consumer.subscriptions.create("NotificationChannel", {
received(data) {
if (data.cableReady) CableReady.perform(data.operations);
},
});
Note: Redis is required for ActionCable to work, so the rest of this article assumes you have a local server running.
4. Delivery Method Implementation
To broadcast system notifications, let's generate a new delivery method:
$ bin/rails generate noticed:delivery_method System
The scaffolded class looks like this:
# app/notifications/delivery_methods/system.rb
class DeliveryMethods::System < Noticed::DeliveryMethods::Base
def deliver
# Logic for sending the notification
end
# You may override this method to validate options for the delivery method
# Invalid options should raise a ValidationError
#
# def self.validate!(options)
# raise ValidationError, "required_option missing" unless options[:required_option]
# end
end
Let's continue by drafting how we envision our deliver
method to work.
class DeliveryMethods::System < Noticed::DeliveryMethods::Base
+ include CableReady::Broadcaster
+
def deliver
- # Logic for sending the notification
+ cable_ready[channel].notification(
+ title: "My App",
+ options: {
+ body: notification.message
+ }
+ ).broadcast_to(recipient)
end
+ def channel
+ @channel ||= begin
+ value = options[:channel]
+ case value
+ when String
+ value.constantize
+ else
+ Noticed::NotificationChannel
+ end
+ end
+ end
- # You may override this method to validate options for the delivery method
- # Invalid options should raise a ValidationError
- #
- # def self.validate!(options)
- # raise ValidationError, "required_option missing" unless options[:required_option]
- # end
end
We have borrowed the channel
method in part from the built-in ActionCable delivery method. It allows us to pass in a channel via a class method option. Otherwise, it falls back to the provided Noticed::NotificationsChannel
.
Then we use CableReady's notification
method to broadcast the respective instance to the recipient.
To put it into action, we have to connect it to our notification PORO:
class TestNotification < Noticed::Base
deliver_by :database
+ deliver_by :system, class: "DeliveryMethods::System", channel: "NotificationChannel"
def message
"A system notification"
end
end
5. Putting It to Work
Now all that's left to do is try it out. We can simply run this again from the Rails console:
irb(main):001:0> TestNotification.deliver(User.first)
Assuming you are still logged in, the browser will first ask you for permission to receive notifications on behalf of the app:
Once you have confirmed this, you get this beautiful pop-up notification from your browser:
Wrapping Up
Taking a fast lane tour through CableReady and Noticed, we have demonstrated how to integrate a native browser API into your app. The result is a simple, coherent way to deliver system notifications to your users.
This use case is also meant to illustrate how easy it is to mix CableReady into your use case. If you think ahead one step, decoupling the drafted delivery method into a library is not hard.
I hope this inspires you to look for manifestations of vertical reactive problem domains in your app and give CableReady a try.
Read more by picking up my new book Advanced CableReady.
Use the coupon code APPSIGNAL-PROMO and save $10!
Happy coding!
P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!
Top comments (0)