Skip to content

Commit f17ff07

Browse files
committed
test web push
1 parent cb24a27 commit f17ff07

File tree

9 files changed

+260
-8
lines changed

9 files changed

+260
-8
lines changed

README.md

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Action Push Native
22

3-
Action Push Native is a Rails push notification gem for mobile platforms, supporting APNs (Apple) and FCM (Google).
3+
Action Push Native is a Rails push notification gem for mobile and web platforms, supporting APNs (Apple) and FCM (Google Android/Web).
44

55
## Installation
66

@@ -87,12 +87,20 @@ shared:
8787
# See https://firebase.google.com/docs/cloud-messaging/auth-server
8888
encryption_key: <%= Rails.application.credentials.dig(:action_push_native, :fcm, :encryption_key)&.dump %>
8989

90+
# Firebase project_id
91+
project_id: your_project_id
92+
93+
web:
94+
# Uses the same Firebase project service account credentials as Android.
95+
# See https://firebase.google.com/docs/cloud-messaging/auth-server
96+
encryption_key: <%= Rails.application.credentials.dig(:action_push_native, :fcm, :encryption_key)&.dump %>
97+
9098
# Firebase project_id
9199
project_id: your_project_id
92100
```
93101
94102
This file contains the configuration for the push notification services you want to use.
95-
The push notification services supported are `apple` (APNs) and `google` (FCM).
103+
The push notification services supported are `apple` (APNs), `google` (FCM Android), and `web` (FCM Web Push).
96104
If you're configuring more than one app, see the section [Configuring multiple apps](#configuring-multiple-apps) below.
97105

98106
### Configuring multiple apps
@@ -208,12 +216,13 @@ notification = ApplicationPushNotification
208216
You can configure custom platform payload to be sent with the notification. This is useful when you
209217
need to send additional data that is specific to the platform you are using.
210218

211-
You can use `with_apple` for Apple and `with_google` for Google:
219+
You can use `with_apple` for Apple, `with_google` for Android, and `with_web` for Web:
212220

213221
```ruby
214222
notification = ApplicationPushNotification
215223
.with_apple(aps: { category: "observable", "thread-id": "greeting"}, "apns-priority": "1")
216224
.with_google(data: { badge: 1 })
225+
.with_web(webpush: { headers: { TTL: "300" }, data: { url: "https://example.com" } })
217226
.new(title: "Hello world!")
218227
```
219228

@@ -223,6 +232,7 @@ default behaviour:
223232
```ruby
224233
notification = ApplicationPushNotification
225234
.with_google(android: { notification: { notification_count: nil } })
235+
.with_web(webpush: { notification: { tag: "custom" } })
226236
.new(title: "Hello world!", body: "Welcome to Action Push Native", badge: 1)
227237
```
228238

@@ -278,6 +288,67 @@ by adding extra arguments to the notification constructor:
278288
notification.deliver_later_to(device)
279289
```
280290

291+
### Registering Web Push Devices via API
292+
293+
For web clients (including TWA-backed PWAs), obtain an FCM registration token in the browser and POST it to your backend as a `web` device.
294+
295+
```js
296+
import { initializeApp } from "firebase/app";
297+
import { getMessaging, getToken, isSupported } from "firebase/messaging";
298+
299+
const firebaseApp = initializeApp({
300+
apiKey: "...",
301+
projectId: "...",
302+
messagingSenderId: "...",
303+
appId: "...",
304+
});
305+
306+
async function registerPushDevice() {
307+
if (!(await isSupported())) return;
308+
309+
const registration = await navigator.serviceWorker.register("/firebase-messaging-sw.js");
310+
const messaging = getMessaging(firebaseApp);
311+
const token = await getToken(messaging, {
312+
serviceWorkerRegistration: registration,
313+
vapidKey: "YOUR_WEB_PUSH_CERTIFICATE_KEY_PAIR_VAPID_KEY",
314+
});
315+
316+
if (!token) return;
317+
318+
await fetch("/push_devices", {
319+
method: "POST",
320+
headers: { "Content-Type": "application/json" },
321+
body: JSON.stringify({
322+
device: {
323+
platform: "web",
324+
token,
325+
name: navigator.userAgent,
326+
},
327+
}),
328+
credentials: "include",
329+
});
330+
}
331+
```
332+
333+
```ruby
334+
# app/controllers/push_devices_controller.rb
335+
class PushDevicesController < ApplicationController
336+
protect_from_forgery with: :null_session
337+
338+
def create
339+
device = ApplicationPushDevice.find_or_initialize_by(token: device_params[:token])
340+
device.assign_attributes(device_params.merge(owner: current_user))
341+
device.save!
342+
head :created
343+
end
344+
345+
private
346+
def device_params
347+
params.require(:device).permit(:platform, :token, :name)
348+
end
349+
end
350+
```
351+
281352
### Using a custom Device model
282353

283354
If using the default `ApplicationPushDevice` model does not fit your needs, you can create a custom
@@ -311,6 +382,7 @@ end
311382
| :sound | The sound to play when the notification is received.
312383
| :high_priority | Whether the notification should be sent with high priority (default: true).
313384
| :google_data | The Google-specific payload for the notification.
385+
| :web_data | The Web-specific payload for the notification (FCM Web).
314386
| :apple_data | The Apple-specific payload for the notification. It can also be used to override APNs request headers, such as `apns-push-type`, `apns-priority`, etc.
315387
| :data | The data payload for the notification, sent to all platforms.
316388
| ** | Any additional attributes passed to the constructor will be merged in the `context` hash.
@@ -321,6 +393,7 @@ end
321393
|------------------|------------
322394
| :with_apple | Set the Apple-specific payload for the notification.
323395
| :with_google | Set the Google-specific payload for the notification. It can also be used to override APNs request headers, such as `apns-push-type`, `apns-priority`, etc.
396+
| :with_web | Set the Web-specific payload for the notification.
324397
| :with_data | Set the data payload for the notification, sent to all platforms.
325398
| :silent | Create a silent notification that does not trigger a visual alert on the device.
326399

app/models/action_push_native/device.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class Device < ApplicationRecord
88

99
belongs_to :owner, polymorphic: true, optional: true
1010

11-
enum :platform, { apple: "apple", google: "google" }
11+
enum :platform, { apple: "apple", google: "google", web: "web" }
1212

1313
def push(notification)
1414
notification.token = token

lib/action_push_native.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ def self.service_for(platform, notification)
2121
Service::Apns.new(platform_config)
2222
when :google
2323
Service::Fcm.new(platform_config)
24+
when :web
25+
Service::FcmWeb.new(platform_config)
2426
else
2527
raise "ActionPushNative: '#{platform}' platform is unsupported"
2628
end

lib/action_push_native/configured_notification.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ def with_google(google_data)
2929
self
3030
end
3131

32+
def with_web(web_data)
33+
@options[:web_data] = @options.fetch(:web_data, {}).merge(web_data)
34+
self
35+
end
36+
3237
private
3338
attr_reader :notification_class, :options
3439
end

lib/action_push_native/notification.rb

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ module ActionPushNative
77
class Notification
88
extend ActiveModel::Callbacks
99

10-
attr_accessor :title, :body, :badge, :thread_id, :sound, :high_priority, :apple_data, :google_data, :data
10+
attr_accessor :title, :body, :badge, :thread_id, :sound, :high_priority, :apple_data, :google_data, :web_data, :data
1111
attr_accessor :context
1212
attr_accessor :token
1313

@@ -22,7 +22,7 @@ def queue_as(name)
2222
self.queue_name = name
2323
end
2424

25-
delegate :with_data, :silent, :with_apple, :with_google, to: :configured_notification
25+
delegate :with_data, :silent, :with_apple, :with_google, :with_web, to: :configured_notification
2626

2727
private
2828
def configured_notification
@@ -40,11 +40,12 @@ def configured_notification
4040
# high_priority - Whether to send the notification with high priority (default: true).
4141
# For silent notifications is recommended to set this to false
4242
# apple_data - Apple Push Notification Service (APNS) specific data
43-
# google_data - Firebase Cloud Messaging (FCM) specific data
43+
# google_data - Firebase Cloud Messaging (FCM) specific data for Android
44+
# web_data - Firebase Cloud Messaging (FCM) specific data for Web Push
4445
# data - Custom data to be sent with the notification
4546
#
4647
# Any extra attributes are set inside the `context` hash.
47-
def initialize(title: nil, body: nil, badge: nil, thread_id: nil, sound: nil, high_priority: true, apple_data: {}, google_data: {}, data: {}, **context)
48+
def initialize(title: nil, body: nil, badge: nil, thread_id: nil, sound: nil, high_priority: true, apple_data: {}, google_data: {}, web_data: {}, data: {}, **context)
4849
@title = title
4950
@body = body
5051
@badge = badge
@@ -53,6 +54,7 @@ def initialize(title: nil, body: nil, badge: nil, thread_id: nil, sound: nil, hi
5354
@high_priority = high_priority
5455
@apple_data = apple_data
5556
@google_data = google_data
57+
@web_data = web_data
5658
@data = data
5759
@context = context
5860
end
@@ -79,6 +81,7 @@ def as_json
7981
high_priority: high_priority,
8082
apple_data: apple_data,
8183
google_data: google_data,
84+
web_data: web_data,
8285
data: data,
8386
**context
8487
}.compact
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen_string_literal: true
2+
3+
module ActionPushNative
4+
module Service
5+
class FcmWeb < Fcm
6+
private
7+
def payload_from(notification)
8+
deep_compact({
9+
message: {
10+
token: notification.token,
11+
data: notification.data ? stringify(notification.data) : {},
12+
webpush: webpush_payload_from(notification)
13+
}.deep_merge(notification.web_data ? stringify_data(notification.web_data) : {})
14+
})
15+
end
16+
17+
def webpush_payload_from(notification)
18+
notification_payload = {
19+
title: notification.title,
20+
body: notification.body,
21+
tag: notification.thread_id
22+
}.compact
23+
24+
headers = urgency_header_for(notification)
25+
26+
{
27+
notification: notification_payload.presence,
28+
headers: headers.presence
29+
}.compact
30+
end
31+
32+
def urgency_header_for(notification)
33+
urgency = notification.high_priority == false ? "normal" : "high"
34+
{ Urgency: urgency }.compact
35+
end
36+
37+
def deep_compact(payload)
38+
payload.dig(:message, :webpush, :notification).try(&:compact!)
39+
payload.dig(:message, :webpush, :headers).try(&:compact!)
40+
payload.dig(:message, :webpush).try(&:compact!)
41+
payload[:message][:data] = payload[:message][:data].presence if payload[:message][:data].respond_to?(:presence)
42+
payload[:message].compact!
43+
payload
44+
end
45+
46+
def stringify_data(web_data)
47+
super.tap do |payload|
48+
if payload[:webpush]&.key?(:data)
49+
payload[:webpush][:data] = stringify(payload[:webpush][:data])
50+
end
51+
end
52+
end
53+
end
54+
end
55+
end

test/fixtures/files/config/push_calendar.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ google:
1111
application:
1212
encryption_key: your_service_account_json_file
1313
project_id: your_project_id
14+
web:
15+
application:
16+
encryption_key: your_service_account_json_file
17+
project_id: your_project_id

test/lib/action_push_native/action_push_test.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,30 @@ class ActionPushNativeTest < ActiveSupport::TestCase
6161
assert_equal expected_config, config
6262
end
6363

64+
test "config_for web" do
65+
stub_config("push_calendar.yml")
66+
config = ActionPushNative.config_for :web, CalendarPushNotification.new
67+
expected_config = {
68+
encryption_key: "your_service_account_json_file",
69+
project_id: "your_project_id"
70+
}
71+
assert_equal expected_config, config
72+
end
73+
74+
test "service_for web" do
75+
notification = CalendarPushNotification.new(title: "Hello Web")
76+
stub_config("push_calendar.yml")
77+
78+
service = ActionPushNative.service_for(:web, notification)
79+
80+
assert_kind_of ActionPushNative::Service::FcmWeb, service
81+
expected_config = {
82+
encryption_key: "your_service_account_json_file",
83+
project_id: "your_project_id"
84+
}
85+
assert_equal expected_config, service.send(:config)
86+
end
87+
6488
private
6589
def stub_config(name)
6690
Rails.application.stubs(:config_for).returns(YAML.load_file(file_fixture("config/#{name}"), symbolize_names: true))
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
require "test_helper"
2+
3+
module ActionPushNative
4+
module Service
5+
class FcmWebTest < ActiveSupport::TestCase
6+
setup do
7+
@notification = build_notification
8+
@fcm_web = ActionPushNative.service_for(:web, @notification)
9+
stub_authorizer
10+
end
11+
12+
test "push" do
13+
payload = {
14+
message: {
15+
token: "123",
16+
data: { person: "Jacopo" },
17+
webpush: {
18+
notification: {
19+
title: "Hi!",
20+
body: "This is a web push notification",
21+
tag: "thread-123"
22+
},
23+
headers: { Urgency: "high" },
24+
data: { badge: "1", url: "https://example.test" }
25+
}
26+
}
27+
}
28+
29+
stub_request(:post, "https://fcm.googleapis.com/v1/projects/your_project_id/messages:send").
30+
with(body: payload.to_json, headers: { "Authorization" => "Bearer fake_access_token" }).
31+
to_return(status: 200)
32+
33+
assert_nothing_raised do
34+
@fcm_web.push(@notification)
35+
end
36+
end
37+
38+
test "push with low priority urgency" do
39+
@notification.high_priority = false
40+
payload = {
41+
message: {
42+
token: "123",
43+
data: { person: "Jacopo" },
44+
webpush: {
45+
notification: {
46+
title: "Hi!",
47+
body: "This is a web push notification",
48+
tag: "thread-123"
49+
},
50+
headers: { Urgency: "normal" },
51+
data: { badge: "1", url: "https://example.test" }
52+
}
53+
}
54+
}
55+
56+
stub_request(:post, "https://fcm.googleapis.com/v1/projects/your_project_id/messages:send").
57+
with(body: payload.to_json, headers: { "Authorization" => "Bearer fake_access_token" }).
58+
to_return(status: 200)
59+
60+
assert_nothing_raised do
61+
@fcm_web.push(@notification)
62+
end
63+
end
64+
65+
private
66+
def build_notification
67+
ActionPushNative::Notification.
68+
with_web(webpush: { data: { badge: 1, url: "https://example.test" } }).
69+
with_data(person: "Jacopo").
70+
new(
71+
title: "Hi!",
72+
body: "This is a web push notification",
73+
thread_id: "thread-123"
74+
).tap do |notification|
75+
notification.token = "123"
76+
end
77+
end
78+
79+
def stub_authorizer
80+
authorizer = stub("authorizer")
81+
authorizer.stubs(:fetch_access_token!).returns({ "access_token" => "fake_access_token", "expires_in" => 3599 })
82+
Google::Auth::ServiceAccountCredentials.stubs(:make_creds).returns(authorizer)
83+
end
84+
end
85+
end
86+
end

0 commit comments

Comments
 (0)