From f685dd1d9ef23efb678ee51d38d742efebc14688 Mon Sep 17 00:00:00 2001 From: Daniel Dickison Date: Thu, 17 Apr 2025 01:52:03 -0700 Subject: [PATCH 1/2] Add declaratiive Notification payload builder --- src/lib.rs | 2 + src/notification.rs | 104 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 src/notification.rs diff --git a/src/lib.rs b/src/lib.rs index 91499f42..5a183de5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,6 +57,7 @@ pub use crate::{ error::WebPushError, http_ece::ContentEncoding, message::{SubscriptionInfo, SubscriptionKeys, Urgency, WebPushMessage, WebPushMessageBuilder, WebPushPayload}, + notification::{Notification, NotificationAction}, vapid::{builder::PartialVapidSignatureBuilder, VapidSignature, VapidSignatureBuilder}, }; @@ -64,4 +65,5 @@ mod clients; mod error; mod http_ece; mod message; +mod notification; mod vapid; diff --git a/src/notification.rs b/src/notification.rs new file mode 100644 index 00000000..246fda74 --- /dev/null +++ b/src/notification.rs @@ -0,0 +1,104 @@ +use serde::Serialize; + +/// Declarative notification that can be used to populate the payload of a web push. +/// +/// See https://webkit.org/blog/16535/meet-declarative-web-push +#[derive(Debug, Serialize)] +pub struct Notification { + pub title: String, + pub navigate: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub lang: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub dir: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub tag: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub badge: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub vibrate: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub renotify: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub silent: Option, + + #[serde(skip_serializing_if = "Option::is_none", rename = "requireInteraction")] + pub require_interaction: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub actions: Option>, +} + +#[derive(Debug, Serialize)] +pub struct NotificationAction { + pub title: String, + pub action: String, + pub navigate: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, +} + +impl Notification { + pub fn new(title: String, navigate: String) -> Self { + Notification { + title, + navigate, + lang: None, + dir: None, + tag: None, + body: None, + icon: None, + image: None, + badge: None, + vibrate: None, + timestamp: None, + renotify: None, + silent: None, + require_interaction: None, + data: None, + actions: None, + } + } + + pub fn to_payload(&self) -> serde_json::Result> { + serde_json::to_vec(&DeclarativePushPayload::new(self)) + } +} + +#[derive(Debug, Serialize)] +struct DeclarativePushPayload<'a, D: Serialize> { + web_push: u16, + pub notification: &'a Notification, +} + +impl<'a, D: Serialize> DeclarativePushPayload<'a, D> { + pub fn new(notification: &'a Notification) -> Self { + DeclarativePushPayload { + web_push: 8030, + notification, + } + } +} From 008be7ac329b70b3f635293eef4247f8b9e34154 Mon Sep 17 00:00:00 2001 From: Daniel Dickison Date: Wed, 22 Apr 2026 22:44:08 -0700 Subject: [PATCH 2/2] Add unit tests for Notification payload --- src/notification.rs | 88 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/notification.rs b/src/notification.rs index 246fda74..1182487e 100644 --- a/src/notification.rs +++ b/src/notification.rs @@ -102,3 +102,91 @@ impl<'a, D: Serialize> DeclarativePushPayload<'a, D> { } } } + +#[cfg(test)] +mod tests { + use serde::Serialize; + use serde_json::Value; + + use super::{Notification, NotificationAction}; + + fn parse_payload(notification: &Notification) -> Value { + let bytes = notification.to_payload().expect("to_payload should not fail"); + serde_json::from_slice(&bytes).expect("payload should be valid JSON") + } + + #[test] + fn test_new_sets_required_fields() { + let n: Notification<()> = Notification::new("Hello".to_string(), "https://example.com/".to_string()); + assert_eq!(n.title, "Hello"); + assert_eq!(n.navigate, "https://example.com/"); + } + + #[test] + fn test_payload_web_push_field_is_rfc8030_magic_value() { + let n: Notification<()> = Notification::new("t".to_string(), "u".to_string()); + let v = parse_payload(&n); + assert_eq!(v["web_push"], 8030); + } + + #[test] + fn test_require_interaction_serializes_as_camel_case() { + let mut n: Notification<()> = Notification::new("t".to_string(), "u".to_string()); + n.require_interaction = Some(true); + let v = parse_payload(&n); + assert_eq!(v["notification"]["requireInteraction"], true); + assert!(v["notification"].get("require_interaction").is_none()); + } + + #[test] + fn test_payload_with_custom_data_struct() { + #[derive(Serialize)] + struct MyData { + user_id: u32, + action: String, + } + + let mut n = Notification::new("t".to_string(), "u".to_string()); + n.data = Some(MyData { + user_id: 42, + action: "open".to_string(), + }); + let v = parse_payload(&n); + assert_eq!(v["notification"]["data"]["user_id"], 42); + assert_eq!(v["notification"]["data"]["action"], "open"); + } + + #[test] + fn test_payload_with_primitive_data() { + let mut n = Notification::new("t".to_string(), "u".to_string()); + n.data = Some("just a string"); + let v = parse_payload(&n); + assert_eq!(v["notification"]["data"], "just a string"); + } + + #[test] + fn test_notification_actions() { + let mut n: Notification<()> = Notification::new("t".to_string(), "u".to_string()); + n.actions = Some(vec![ + NotificationAction { + title: "Accept".to_string(), + action: "accept".to_string(), + navigate: "https://example.com/accept".to_string(), + icon: None, + }, + NotificationAction { + title: "Decline".to_string(), + action: "decline".to_string(), + navigate: "https://example.com/decline".to_string(), + icon: Some("https://example.com/decline-icon.png".to_string()), + }, + ]); + let v = parse_payload(&n); + let actions = v["notification"]["actions"].as_array().unwrap(); + assert_eq!(actions.len(), 2); + assert_eq!(actions[0]["action"], "accept"); + assert!(actions[0].get("icon").is_none()); + assert_eq!(actions[1]["action"], "decline"); + assert_eq!(actions[1]["icon"], "https://example.com/decline-icon.png"); + } +}