Implementing Web Push Notifications in a Ruby on Rails Application

Dejan Vujovic
12 min readOct 4, 2023

Introduction:

Web push notifications have become an integral part of user engagement strategies for web applications. In this article, we will delve into the intricacies of web push notifications and learn how to implement them in a Ruby on Rails application. We’ll also explore the benefits of using this feature and the underlying technology.

What Are Web Push Notifications?

Web push notifications are messages that pop up on a user’s device or desktop even when they are not actively using a website or application. These notifications are delivered via the user’s web browser and can display important information, updates, or personalized messages, helping businesses engage with their audience more effectively.

How Do Web Push Notifications Work: The core components of web push notifications include:

  1. Service Workers: These are JavaScript scripts that run in the background, independent of the web page. Service workers manage push notifications, intercept network requests, and enable background synchronization.
  2. Push API: This browser feature allows websites to request permission from users to send push notifications. Once granted, it establishes a communication channel between the website and the user’s device.
  3. Push Server: A push server, often provided by a third-party service like Firebase Cloud Messaging (FCM) or OneSignal, sends notifications to the user’s device when an event triggers a notification.

Here’s how the process works:

  • A user visits a website and grants permission for push notifications.
  • The website registers a service worker that subscribes to a push server using an API key.
  • When a relevant event occurs, such as a new message or a content update, the server sends a push message to the user’s browser.
  • The service worker receives the push message and displays a notification on the user’s device, even if the website is not open in the browser.

Implementing Web Push Notifications in a Ruby on Rails Application

GitHub Repository — Feature Overview: I’ve created a GitHub repository that serves as a comprehensive template for Ruby on Rails applications, including the following features:

An administrative functionality has been integrated, enabling administrators to send push notifications.

Furthermore, both front-end and server-side code for dispatching push notifications to users’ browsers have been meticulously implemented.

You can clone this repository and verify the flawless functionality. The entire codebase has been rigorously tested, and particular attention has been given to the Jest test setup with Stimulus helper and the necessary dependencies.

In this article, we’ll explore the process of implementing push notifications in a Ruby on Rails application using the web-push gem. Before we dive into the technical details, let’s first understand the concept of VAPID keys and the role of the web-push gem.

Understanding VAPID Keys

VAPID (Voluntary Application Server Identification) keys are an essential component of the push notification ecosystem. They are used to identify the server that sends push notifications to the client’s browser securely. VAPID keys consist of a public key and a private key. The public key is shared with the client’s browser, while the private key is kept secret on the server.

The purpose of VAPID keys is to ensure the security and authenticity of push notifications. They establish a trust relationship between the web application and the user’s browser, allowing for secure communication.

To simplify the process of implementing push notifications in your Ruby on Rails application, we’ll be using the web-push gem. This gem provides a convenient way to work with VAPID keys, service workers, and push notifications.

Installation and Configuration

To get started, follow these steps:

  1. Install the web-push gem by adding it to your Gemfile and running bundle install.
  2. Generate VAPID keys using the webpush command-line tool provided by the gem. These keys will be used to identify your server. Store them securely.
  3. In the <body> tag of your application layout, add a data-controller attribute with the name of your push controller and a data-application-server-key attribute with the VAPID public key as its value. For example:
<body data-controller="push" data-application-server-key="<%= ENV['DEFAULT_APPLICATION_SERVER_KEY'] %>">

Here, we’re using environment variables for the VAPID keys. In a production environment, consider using Rails credentials to store sensitive information securely.

Step 1: First, we implement a Service Worker and place the service_worker.js file in the public directory so that Stimulus controller can register it.

const options = {
title: notificationData.title,
body: notificationData.body,
icon: notificationData.icon,
};

event.waitUntil(
self.registration.showNotification(notificationData.title, options)
);
});

This code handles push notifications. It listens for a ‘push’ event and, when triggered, parses the notification data, including the title, body, and icon. Then, it uses this data to display a notification on the user’s device. The event.waitUntil ensures that the notification is displayed properly.

You can find this code in Chrome DevTools, specifically in the “Application” tab, under the “Service Workers” section. Here, you can check if it’s registered and active.

Stimulus Controller (push_controller.js):

This Stimulus controller handles the registration of a Service Worker and the subscription to push notifications for a web application.

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
connect() {
// Check if the browser supports notifications
if ("Notification" in window) {
// Request permission from the user to send notifications
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
// If permission is granted, register the service worker
this.registerServiceWorker();
} else if (permission === "denied") {
console.warn("User rejected to allow notifications.");
} else {
console.warn("User still didn't give an answer about notifications.");
}
});
} else {
console.warn("Push notifications not supported.");
}
}

registerServiceWorker() {
// Check if the browser supports service workers
if ("serviceWorker" in navigator) {
// Register the service worker script (service_worker.js)
navigator.serviceWorker
.register('service_worker.js')
.then((serviceWorkerRegistration) => {
// Check if a subscription to push notifications already exists
serviceWorkerRegistration.pushManager
.getSubscription()
.then((existingSubscription) => {
if (!existingSubscription) {
// If no subscription exists, subscribe to push notifications
serviceWorkerRegistration.pushManager
.subscribe({
userVisibleOnly: true,
applicationServerKey: this.element.getAttribute(
"data-application-server-key"
),
})
.then((subscription) => {
// Save the subscription on the server
this.saveSubscription(subscription);
});
}
});
})
.catch((error) => {
console.error("Error during registration Service Worker:", error);
});
}
}

saveSubscription(subscription) {
// Extract necessary subscription data
const endpoint = subscription.endpoint;
const p256dh = btoa(
String.fromCharCode.apply(
null,
new Uint8Array(subscription.getKey("p256dh"))
)
);
const auth = btoa(
String.fromCharCode.apply(
null,
new Uint8Array(subscription.getKey("auth"))
)
);

// Send the subscription data to the server
fetch("/admin/push_notifications/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-CSRF-Token": document
.querySelector('meta[name="csrf-token"]')
.getAttribute("content"),
},
body: JSON.stringify({ endpoint, p256dh, auth }),
})
.then((response) => {
if (response.ok) {
console.log("Subscription successfully saved on the server.");
} else {
console.error("Error saving subscription on the server.");
}
})
.catch((error) => {
console.error("Error sending subscription to the server:", error);
});
}
}

Here’s a step-by-step breakdown:

  1. In the connect method, it first checks if the browser supports notifications. If not, it logs a warning that push notifications are not supported.
  2. If the browser does support notifications, it requests permission from the user to send notifications using the Notification.requestPermission() method.
  3. Depending on the user’s response to the permission request, it either registers the service worker or logs a warning if the user denies or hasn’t responded to the notification request.
  4. The registerServiceWorker method, when called, checks if the browser supports service workers. If supported, it registers the service worker script specified in 'service_worker.js'.
  5. It then checks if there’s already an existing subscription for push notifications. If not, it subscribes to push notifications, ensuring that they are user-visible only and providing an application server key.
  6. After successfully subscribing to push notifications, it extracts the necessary subscription data, such as the endpoint, p256dh, and auth, and sends this data to the server via a POST request to the /admin/push_notifications/subscribe endpoint.
  7. The saveSubscription method handles the sending of the subscription data to the server, including the necessary headers and a JSON payload.

In summary, this Stimulus controller orchestrates the setup of push notifications in a web application, including requesting user permission, registering a service worker, subscribing to push notifications, and saving the subscription on the server. It ensures a smooth user experience for receiving push notifications.

Here we will write simple test for stimulus controller:

import { setHTML, startStimulus } from "../_stimulus_helper";
import PushController from "../../../app/javascript/controllers/push_controller";

beforeEach(() => startStimulus("push-notification", PushController));
const bodyElement = document.querySelector('body');
const applicationServerKeyValue = bodyElement.getAttribute('data-application-server-key');
test("it registers and saves a push subscription when permission is granted", async () => {
await setHTML(`
<div data-controller="push" data-application-server-key="${applicationServerKeyValue}">
<button data-action="click->push#connect">Connect</button>
</div>
`);

const connectButton = document.querySelector(
'[data-action="click->push#connect"]'
);

connectButton.click();
});

By running this test, we can ensure that our Stimulus controller is working as intended and handling the push notification setup gracefully when the user interacts with the “Connect” button.

Step 2: Usually, push notifications are sent when new content is added, etc., in an application. However, here, we will create push notifications with an admin. When a Push notification is created, it is sent to the user’s browser.

We will create models for Push Notifications and Push Subscriptions to store data from the browser.

The PushNotification model serves as a fundamental component of our push notification system in our Ruby on Rails application. Its primary purpose is to encapsulate the essential information required to compose and send push notifications.

class PushNotification < ApplicationRecord
validates :title, presence: true
validates :body, presence: true

after_create_commit lambda {
broadcast_prepend_to "push_notifications", partial: "admin/push_notifications/push_notification",
locals: { push_notification: self }, target: "push_notifications"
}
end

Rspec test:

require 'rails_helper'

RSpec.describe PushNotification, type: :model do
it 'is valid with valid attributes' do
push_notification = PushNotification.new(
title: 'Test Title',
body: 'Test Body'
)
expect(push_notification).to be_valid
end

it 'is not valid without a title' do
push_notification = PushNotification.new(
title: nil,
body: 'Test Body'
)
expect(push_notification).not_to be_valid
end

it 'is not valid without a body' do
push_notification = PushNotification.new(
title: 'Test Title',
body: nil
)
expect(push_notification).not_to be_valid
end
end

The PushSubscription model complements the push notification system by capturing and storing essential data from the user's browser. This data is pivotal for establishing a connection with the user's browser and sending push notifications effectively. Let's explore its key attributes and purpose.

  • endpoint, p256dh, and auth are attributes that play a crucial role in setting up push subscriptions. They contain essential information required to create a secure and reliable connection between the application server and the user's browser.
  • While not explicitly mentioned in the code, the subscribed attribute could be used to indicate whether the user has actively subscribed to push notifications. It can be valuable for tracking user preferences regarding notifications.
class PushSubscription < ApplicationRecord
validates :endpoint, presence: true
validates :p256dh, presence: true
validates :auth, presence: true
end

Rspec model test:

require 'rails_helper'

RSpec.describe PushSubscription, type: :model do
let(:valid_attributes) do
{
endpoint: 'https://example.com/endpoint',
p256dh: 'sample_p256dh_key',
auth: 'sample_auth_key',
subscribed: true
}
end

it 'is valid with valid attributes' do
push_subscription = PushSubscription.new(valid_attributes)
expect(push_subscription).to be_valid
end

it 'is not valid without an endpoint' do
invalid_attributes = valid_attributes.merge(endpoint: nil)
push_subscription = PushSubscription.new(invalid_attributes)
expect(push_subscription).not_to be_valid
end

it 'is not valid without a p256dh key' do
invalid_attributes = valid_attributes.merge(p256dh: nil)
push_subscription = PushSubscription.new(invalid_attributes)
expect(push_subscription).not_to be_valid
end

it 'is not valid without an auth key' do
invalid_attributes = valid_attributes.merge(auth: nil)
push_subscription = PushSubscription.new(invalid_attributes)
expect(push_subscription).not_to be_valid
end
end

Finally, we need a controller and routes. Since the admin creates notifications, the controller should be like this:

module Admin
class PushNotificationsController < ApplicationController
layout "admin"
before_action :authenticate_user!, except: [:subscribe]
before_action :authorize_admin!, except: [:subscribe]
before_action :set_push_notification, only: %i[show edit update destroy]

def index
@push_notifications = PushNotification.all
@page_title = "Push Notifications"
end

def show
@page_title = "Push Notification Details"
end

def new
@push_notification = PushNotification.new
end

def create
@push_notification = PushNotification.new(push_notification_params)

if @push_notification.save
send_push_notification(@push_notification)
respond_to do |format|
format.html do
redirect_to admin_push_notifications_path, notice: "Push Notification was successfully created"
end
format.turbo_stream { flash.now[:notice] = "Push Notification was successfully created" }
end
else
render :new, status: :unprocessable_entity
end
end

def destroy
@push_notification.destroy
respond_to do |format|
format.html do
redirect_to admin_push_notifications_path, notice: "Push Notification was successfully destroyed."
end
format.turbo_stream do
flash.now[:notice] = "Push Notification was successfully destroyed."
end
end
end

def subscribe
subscription = PushSubscription.new(
endpoint: params[:endpoint],
p256dh: params[:p256dh],
auth: params[:auth],
subscribed: true
)

if subscription.save
render json: { message: "Subscription successfully saved" }, status: :ok
else
render json: { error: "Error in storing subscription" }, status: :unprocessable_entity
end
end

private

def set_push_notification
@push_notification = PushNotification.find(params[:id])
end

def push_notification_params
params.require(:push_notification).permit(:title, :body)
end

def send_push_notification(push_notification)
subscriptions = active_push_subscriptions
latest_subscription = subscriptions.last
return unless latest_subscription

message = build_push_message(push_notification)
vapid_details = build_vapid_details
send_web_push_notification(message, latest_subscription, vapid_details)
end

def active_push_subscriptions
PushSubscription.where(subscribed: true)
end

def build_push_message(push_notification)
{
title: push_notification.title,
body: push_notification.body,
icon: push_notification_icon_url
}
end

def push_notification_icon_url
ActionController::Base.helpers.image_url("note.png")
end

def build_vapid_details
{
subject: "mailto:#{ENV['DEFAULT_EMAIL']}",
public_key: ENV["DEFAULT_APPLICATION_SERVER_KEY"],
private_key: ENV["DEFAULT_PRIVATE_KEY"]
}
end

def send_web_push_notification(message, subscription, vapid_details)
WebPush.payload_send(
message: JSON.generate(message),
endpoint: subscription.endpoint,
p256dh: subscription.p256dh,
auth: subscription.auth,
vapid: vapid_details
)
end

def authorize_admin!
return if current_user.admin?

redirect_to root_path, alert: "You are not authorized to access this page."
end
end
end

A Detailed Explanation:

In the context of a Ruby on Rails web application, the Push Notifications Controller is a crucial component responsible for managing push notifications. Here’s a detailed explanation of its functionality:

1. Layout Setting:

  • The controller sets the layout to “admin,” indicating that it should use a specific layout design for rendering views. In this case, it’s likely a custom layout tailored for administrative purposes.

2. Before-Action Filters:

  • before_action filters are applied before executing specific controller actions to ensure certain conditions are met:
  • authenticate_user!: Ensures that the user is authenticated before performing any action except for the "subscribe" action. Authentication ensures that the user is logged in and authorized to access the admin functionalities.
  • authorize_admin!: Checks whether the current user has administrative privileges. If not, it redirects the user to the root path with an alert, restricting unauthorized access to admin features.

3. Controller Actions:

  • index: Lists all push notifications, retrieving them from the database. It also sets the page title to "Push Notifications."
  • show: Displays detailed information about a specific push notification.
  • new: Initializes a new push notification object, preparing it for creation.
  • create: Creates a new push notification with data provided by the admin. If successfully created, it sends the notification and responds to HTML and Turbo Stream requests with appropriate messages.
  • destroy: Deletes a push notification. Like the create action, it handles HTML and Turbo Stream responses to confirm the deletion.
  • subscribe: Handles subscription requests initiated by users. It saves subscription data (e.g., endpoint, keys) to the database and responds with JSON messages to indicate the success or failure of the subscription process.

4. Private Methods:

  • set_push_notification: A helper method that retrieves a specific push notification based on its ID, making it available for actions like show, edit, update, and destroy.
  • push_notification_params: Defines the parameters permitted for creating a new push notification, ensuring data integrity and security.
  • send_push_notification: Sends a push notification to active subscribers. It determines which subscription to use, constructs the notification message, and sends it using the WebPush library.
  • active_push_subscriptions: Retrieves a list of active push subscriptions from the database, ensuring that notifications are sent only to users who have subscribed.
  • build_push_message: Constructs the content of the push notification message, including its title, body, and icon.
  • push_notification_icon_url: Determines the URL for the notification icon, which can be displayed alongside the notification message.
  • build_vapid_details: Constructs the VAPID (Voluntary Application Server Identification) details, which are essential for secure communication with push services.
  • send_web_push_notification: Utilizes the WebPush library to send the web push notification to a specific subscription.
  • authorize_admin!: A method that checks if the current user has administrative privileges. If not, it redirects unauthorized users to the root path with an alert.

In summary, this controller is responsible for creating, managing, and delivering push notifications in a Ruby on Rails application. It ensures that notifications are sent securely, only to authorized users, and provides the necessary actions for administrators to interact with and manage notifications effectively.

Rspec request test:

RSpec.describe 'Admin::PushNotificationsController', type: :request do
let(:user) { FactoryBot.create(:user, role: 1) }

before do
user.update(verified: true)
post user_session_path, params: { user: { email: user.email, password: user.password } }
end

describe 'GET /admin/push_notifications' do
it 'returns a successful response' do
get admin_push_notifications_path
expect(response).to have_http_status(:ok)
end
end

describe 'GET /admin/push_notifications/:id' do
let(:push_notification) { FactoryBot.create(:push_notification) }

it 'returns a successful response' do
get admin_push_notification_path(push_notification)
expect(response).to have_http_status(:ok)
end
end

describe 'GET /admin/push_notifications/new' do
it 'returns a successful response' do
get new_admin_push_notification_path
expect(response).to have_http_status(:ok)
end
end

describe 'POST /admin/push_notifications' do
let(:valid_attributes) { FactoryBot.attributes_for(:push_notification) }

context 'with valid attributes' do
it 'creates a new push notification' do
expect do
post admin_push_notifications_path, params: { push_notification: valid_attributes }
end.to change(PushNotification, :count).by(1)
end

it 'redirects to the index page' do
post admin_push_notifications_path, params: { push_notification: valid_attributes }
expect(response).to redirect_to(admin_push_notifications_path)
end
end

context 'with invalid attributes' do
it 'does not create a new push notification' do
expect do
post admin_push_notifications_path, params: { push_notification: { title: nil, body: nil } }
end.not_to change(PushNotification, :count)
end

it 'renders the new template' do
post admin_push_notifications_path, params: { push_notification: { title: nil, body: nil } }
expect(response).to render_template(:new)
end
end
end

describe 'DELETE /admin/push_notifications/:id' do
let(:push_notification) { FactoryBot.create(:push_notification) }

it 'destroys the push notification' do
push_notification
expect do
delete admin_push_notification_path(push_notification)
end.to change(PushNotification, :count).by(-1)
end

it 'redirects to the index page' do
delete admin_push_notification_path(push_notification)
expect(response).to redirect_to(admin_push_notifications_path)
end
end

describe 'POST /admin/push_notifications/subscribe' do
let(:subscription_params) { FactoryBot.attributes_for(:push_subscription) }

it 'creates a new push subscription' do
expect do
post subscribe_admin_push_notifications_path, params: subscription_params
end.to change(PushSubscription, :count).by(1)
expect(response).to have_http_status(:ok)
end

it 'returns an error for invalid subscription params' do
post subscribe_admin_push_notifications_path, params: subscription_params.merge(endpoint: nil)
expect(response).to have_http_status(:unprocessable_entity)
end
end
end

Conclusion:

Implementing web push notifications in your Ruby on Rails application can significantly enhance user engagement and keep your users informed. By following the steps outlined in this article and writing thorough tests, you can ensure a smooth and reliable push notification system for your application.

Happy coding!

--

--