Crafting user notifications in Rails with Active Delivery
Vladimir Dementyev
Posted on January 15, 2019
Rails framework is a like a Swiss-army knife, providing a lot of useful functionality out-of-the-box (and it's becoming even more Swiss-er).
It's built on top of the sub-frameworks, such as, to name a few, ActiveRecord, ActiveJob, ActionCable (❤️), ActionMailer... Ok, let's stop at this point.
What is the purpose of ActionMailer?
ActionMailer is an abstraction to send emails (and receive too, though now we have ActionMailbox).
It abstracts the delivery mechanism and provides a Railsy API to build messages.
So, sending email notifications to users is not a big problem for Rails apps.
The problem is that in a modern world we have many different ways to send notifications, not only emails: push notifications, chatbots, SMS, pigeons.
NOTE: DDH mentioned some "action notifier" framework, "yet to be extracted" from Basecamp, which sounds like a solution; but we're not there yet.
It's pretty common to have a code like this:
def notify_user(user)
MyMailer.with(user: user).some_action.deliver_later if user.receive_emails?
SmsSender.send_message(user, "Something happened") if user.receive_sms?
NotifyService.send_notification(user, "action") if whatever_else?
end
And there could be dozens of such places in the codebase. Good luck with maintaining and testing this code!
How can we refactor this code? Maybe, we need an another layer of abstraction?)
Here comes Active Delivery–a new gem I wrote to solve this puzzle.
Active Delivery is a framework providing an entry point for all types of notifications: mailers, push notifications, whatever you want.
It helps you to rewrite the code above in the following way:
def notify_user(user)
MyDelivery.with(user: user).notify(:some_action)
end
And even more–you can now test it elegantly:
# my_something_spec.rb
expect { subject }.to have_delivered_to(MyDelivery, :some_action).
with(user: user)
How does it work?
In the simplest case, a delivery is just a wrapper over a mailer:
# suppose that you have a mailer class
class MyMailer < ApplicationMailer
def some_action
# ...
end
end
# the corresponding delivery could look like this
class MyDelivery < ActiveDelivery::Base
# here we can also apply "delivery rules"
before_notify :ensure_receive_emails, on: :mailer
def ensure_receive_emails
# returning `false` halts the execution
params[:user].receive_emails?
end
end
# when you call
MyDelivery.with(user: user).notify(:some_action)
# it invokes under the hood (only if user receives emails)
MyMailer.with(user: user).some_action.deliver_later
We rely on convention over configuration to infer the corresponding mailer class.
OK. We've just wrapped our mailer. What's the deal? How to handle other delivery methods?
Let's take a look at the architecture of the framework:
Notice that we have an internal layer here–lines. Each line is a connector between the delivery and the actual notification channel (e.g., mailer).
Active Delivery provides an API to add custom delivery lines–that's how you can implement pretty much any type of notifications!
And to make it even easier, we've built another micro-framework–Abstract Notifier.
It's a very abstract framework: all it does is provides an Action Mailer-like API for describing notifier classes, pure Ruby abstraction, zero knowledge of "how to send notifications."
Why Action Mailer-like interface? It's a familiar and continent API, first of all. And I like it's parameterized classes feature (which we heavily use in Active Delivery).
To "teach" Abstract Notifier how to send notifications, you must implement a driver (any callable object).
For example, we use Twilio Notify for push notifications, and that's how our driver, ApplicationDelivery
and ApplicationNotifier
classes look like:
class TwilioDriver
attr_reader :service
def initialize(service_id)
client = build_twilio_api_client
@service = client.notify.services(service_id)
end
def call(params)
service.notifications.create(params)
end
end
class ApplicationDelivery < ActiveDelivery::Base
# NOTE: abstract_notifier automatically registers its default line,
# you don't have to do that
#
# Default notifier infers notifier classes replacing "*Delivery* with
# "*Notifier"
register_line :notifier, ActiveDelivery::Lines::Notifier
end
class ApplicationNotifier < AbstractNotifier::Base
self.driver = TwilioDriver.new(Rails.application.config.twilio_notify_id)
end
Now let's define our delivery, mailer and notifier classes:
class PostsDelivery < ApplicationDelivery
# here we can define callbacks, for example,
# we want to enforce passing a target user as a param
before_notify :ensure_user_provided
def ensure_user_provided
raise ArgumentError, "User must be passed as a param" unless params[:user].is_a?(User)
end
# in our case we have a convenient params-reader method
def user
params[:user]
end
end
class PostsMailer < ApplicationMailer
def published(post)
mail(
to: user.email,
subject: "Post #{post.title} has been published"
)
end
end
class PostsNotifier < ApplicationNotifier
# Btw, we can specify default notification fields
default action: "POSTS"
def published(post)
notification(
body: "Post #{post.title} has been published",
identity: user.twilio_notify_id
# you can pass here any fields supported by your driver
)
end
end
And, finally, that's how we trigger the notification:
PostsDelivery.with(user: user).notify(:published, post)
What if need one more notification channel? We can add another notifier line to our ApplicationDelivery
:
class ApplicationDelivery < ActiveDelivery::Base
register_line :notifier, ActiveDelivery::Lines::Notifier
register_line :pigeon,
ActiveDelivery::Lines::Notifier,
# resolver is responsible for inferring
# the notifier class from
# the delivery class name
resolver: ->(name) { name.gsub(/Delivery$/, "Pigeon").safe_constantize }
end
class PigeonNotifier < AbstractNotifier::Base
self.driver = PigeonDelivery.new
end
class PostsPigeon < PigeonNotifier
def published(post)
notification(
to: user.pigeon_nest_id,
message: "coo-chi coo-chi coo"
)
end
end
That's it 🐦!
Check out Active Delivery and Abstract Notifier repos for more technical information.
Read more dev articles on https://evilmartians.com/chronicles!
Posted on January 15, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.