Skip to main content

apns_h2/request/
payload.rs

1/// Payload with `aps` and custom data
2use crate::error::Error;
3use crate::request::notification::{DefaultAlert, DefaultSound, NotificationOptions, WebPushAlert};
4use erased_serde::Serialize;
5use serde_json::{self, Value};
6use std::borrow::Cow;
7use std::collections::BTreeMap;
8use std::fmt::Debug;
9
10/// The data and options for a push notification.
11#[derive(Debug, Clone, Serialize)]
12pub struct Payload<'a> {
13    /// Send options
14    #[serde(skip)]
15    pub options: NotificationOptions<'a>,
16    /// The token for the receiving device
17    #[serde(skip)]
18    pub device_token: Cow<'a, str>,
19    /// The pre-defined notification payload
20    pub aps: APS<'a>,
21    /// Application specific payload
22    #[serde(flatten)]
23    pub data: BTreeMap<Cow<'a, str>, Value>,
24}
25
26/// Object that can be serialized to create an APNS request.
27/// You probably just want to use [`Payload`], which implements [`PayloadLike`].
28///
29/// # Example
30/// ```no_run
31/// use apns_h2::request::notification::{NotificationBuilder, NotificationOptions};
32/// use apns_h2::request::payload::{PayloadLike, APS};
33/// use apns_h2::{Client, ClientConfig, DefaultNotificationBuilder, Endpoint};
34/// use serde::Serialize;
35/// use std::fs::File;
36///
37/// async fn send() -> Result<(), Box<dyn std::error::Error>> {
38///     let builder = DefaultNotificationBuilder::new()
39///         .body("Hi there")
40///         .badge(420)
41///         .category("cat1")
42///         .sound("ping.flac");
43///
44///     let payload = builder.build("device-token-from-the-user", Default::default());
45///     let mut file = File::open("/path/to/private_key.p8")?;
46///
47///     let client = Client::token(&mut file, "KEY_ID", "TEAM_ID", ClientConfig::default()).unwrap();
48///
49///     let response = client.send(payload).await?;
50///     println!("Sent: {:?}", response);
51///     Ok(())
52/// }
53///
54/// #[derive(Serialize, Debug)]
55/// struct Payload<'a> {
56///     aps: APS<'a>,
57///     my_custom_value: String,
58///     #[serde(skip_serializing)]
59///     options: NotificationOptions<'a>,
60///     #[serde(skip_serializing)]
61///     device_token: &'a str,
62/// }
63///
64/// impl<'a> PayloadLike for Payload<'a> {
65///     fn get_device_token(&self) -> &'a str {
66///         self.device_token
67///     }
68///     fn get_options(&self) -> &NotificationOptions<'_> {
69///         &self.options
70///     }
71/// }
72/// ```
73pub trait PayloadLike: serde::Serialize + Debug {
74    /// Combine the APS payload and the custom data to a final payload JSON.
75    /// Returns an error if serialization fails.
76    #[allow(clippy::wrong_self_convention)]
77    fn to_json_string(&self) -> Result<String, Error> {
78        Ok(serde_json::to_string(&self)?)
79    }
80
81    /// Returns token for the device
82    fn get_device_token(&self) -> &str;
83
84    /// Gets [`NotificationOptions`] for this Payload.
85    fn get_options(&self) -> &NotificationOptions<'_>;
86}
87
88impl<'a> PayloadLike for Payload<'a> {
89    fn get_device_token(&self) -> &str {
90        &self.device_token
91    }
92
93    fn get_options(&self) -> &NotificationOptions<'_> {
94        &self.options
95    }
96}
97
98impl<'a> Payload<'a> {
99    /// Client-specific custom data to be added in the payload.
100    /// The `root_key` defines the JSON key in the root of the request
101    /// data, and `data` the object containing custom data. The `data`
102    /// should implement `Serialize`, which allows using of any Rust
103    /// collection or if needing more strict type definitions, any struct
104    /// that has `#[derive(Serialize)]` from [Serde](https://serde.rs).
105    ///
106    /// Using a `HashMap`:
107    ///
108    /// ```rust
109    /// # use apns_h2::request::notification::{DefaultNotificationBuilder, NotificationBuilder};
110    /// # use std::collections::HashMap;
111    /// # use apns_h2::request::payload::PayloadLike;
112    /// # fn main() {
113    /// let mut payload = DefaultNotificationBuilder::new()
114    ///     .content_available()
115    ///     .build("token", Default::default());
116    /// let mut custom_data = HashMap::new();
117    ///
118    /// custom_data.insert("foo", "bar");
119    /// payload.add_custom_data("foo_data", &custom_data).unwrap();
120    ///
121    /// assert_eq!(
122    ///     "{\"aps\":{\"content-available\":1,\"mutable-content\":0},\"foo_data\":{\"foo\":\"bar\"}}",
123    ///     &payload.to_json_string().unwrap()
124    /// );
125    /// # }
126    /// ```
127    ///
128    /// Using a custom struct:
129    ///
130    /// ```rust
131    /// #[macro_use] extern crate serde;
132    /// use apns_h2::request::notification::{DefaultNotificationBuilder, NotificationBuilder};
133    /// use apns_h2::request::payload::PayloadLike;
134    /// fn main() {
135    /// #[derive(Serialize)]
136    /// struct CompanyData {
137    ///     foo: &'static str,
138    /// }
139    ///
140    /// let mut payload = DefaultNotificationBuilder::new()
141    ///     .content_available()
142    ///     .build("token", Default::default());
143    /// let mut custom_data = CompanyData { foo: "bar" };
144    ///
145    /// payload.add_custom_data("foo_data", &custom_data).unwrap();
146    ///
147    /// assert_eq!(
148    ///     "{\"aps\":{\"content-available\":1,\"mutable-content\":0},\"foo_data\":{\"foo\":\"bar\"}}",
149    ///     &payload.to_json_string().unwrap()
150    /// );
151    /// }
152    /// ```
153    pub fn add_custom_data(
154        &mut self,
155        root_key: impl Into<Cow<'a, str>>,
156        data: &dyn Serialize,
157    ) -> Result<&mut Self, Error> {
158        self.data.insert(root_key.into(), serde_json::to_value(data)?);
159
160        Ok(self)
161    }
162}
163
164/// The pre-defined notification data.
165#[derive(Serialize, Default, Debug, Clone)]
166#[serde(rename_all = "kebab-case")]
167#[allow(clippy::upper_case_acronyms)]
168pub struct APS<'a> {
169    /// The notification content. Can be empty for silent notifications.
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub alert: Option<APSAlert<'a>>,
172
173    /// A number shown on top of the app icon.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub badge: Option<u32>,
176
177    /// The name of the sound file to play when user receives the notification.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub sound: Option<APSSound<'a>>,
180
181    /// An app-specific identifier for grouping related notifications.
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub thread_id: Option<Cow<'a, str>>,
184
185    /// Set to one for silent notifications.
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub content_available: Option<u8>,
188
189    /// When a notification includes the category key, the system displays the
190    /// actions for that category as buttons in the banner or alert interface.
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub category: Option<Cow<'a, str>>,
193
194    /// If set to one, the app can change the notification content before
195    /// displaying it to the user.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub mutable_content: Option<u8>,
198
199    /// Interruption level for the notification. Controls how the notification
200    /// is presented to the user and what system settings it can bypass.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub interruption_level: Option<InterruptionLevel>,
203
204    /// The date when the system should automatically remove the notification.
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub dismissal_date: Option<u64>,
207
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub url_args: Option<Vec<Cow<'a, str>>>,
210
211    /// Live Activity: Timestamp for the Live Activity update.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub timestamp: Option<u64>,
214
215    /// Live Activity: Event type ("start" to begin a Live Activity).
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub event: Option<Cow<'a, str>>,
218
219    /// Live Activity: Content state with dynamic data for the Live Activity.
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub content_state: Option<Value>,
222
223    /// Live Activity: Type of attributes for the Live Activity.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub attributes_type: Option<Cow<'a, str>>,
226
227    /// Live Activity: Attributes data for the Live Activity.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub attributes: Option<Value>,
230
231    /// Live Activity: Input push channel ID for iOS 18+ channel-based updates.
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub input_push_channel: Option<Cow<'a, str>>,
234
235    /// Live Activity: Set to 1 to request a new push token for iOS 18+ token-based updates.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub input_push_token: Option<u8>,
238}
239
240/// Different notification content types.
241#[derive(Serialize, Debug, Clone)]
242#[serde(untagged)]
243pub enum APSAlert<'a> {
244    /// A notification that supports all of the iOS features
245    Default(Box<DefaultAlert<'a>>),
246    /// Safari web push notification
247    WebPush(WebPushAlert<'a>),
248    /// A notification with just a body
249    #[deprecated(since = "0.11.0", note = "Use `APSAlert::body_only(body)`")]
250    Body(Cow<'a, str>),
251}
252
253impl<'a> APSAlert<'a> {
254    /// Creates an alert with only the body set.
255    ///
256    /// ```rust
257    /// # use apns_h2::request::notification::{DefaultNotificationBuilder, NotificationBuilder};
258    /// # use apns_h2::request::payload::{APSAlert, APS, Payload, PayloadLike};
259    /// # fn main() {
260    /// let alert = APSAlert::body_only("another body");
261    /// let payload = Payload {
262    ///     device_token: "token".into(),
263    ///     aps: APS {
264    ///         alert: Some(alert),
265    ///         ..Default::default()
266    ///     },
267    ///     options: Default::default(),
268    ///     data: Default::default(),
269    /// };
270    ///
271    /// assert_eq!(
272    ///     "{\"aps\":{\"alert\":{\"body\":\"another body\"}}}",
273    ///     &payload.to_json_string().unwrap()
274    /// );
275    /// # }
276    /// ```
277    pub fn body_only(body: impl Into<Cow<'a, str>>) -> Self {
278        APSAlert::Default(Box::new(DefaultAlert::body_only(body.into())))
279    }
280}
281
282/// Different notification sound types.
283#[derive(Serialize, Debug, Clone)]
284#[serde(untagged)]
285pub enum APSSound<'a> {
286    /// A critical notification (supported only on >= iOS 12)
287    Critical(DefaultSound<'a>),
288    /// Name for a notification sound
289    Sound(Cow<'a, str>),
290}
291
292/// Interruption level for notification delivery and presentation.
293#[derive(Serialize, Debug, Clone)]
294#[serde(rename_all = "kebab-case")]
295pub enum InterruptionLevel {
296    /// The system presents the notification immediately, lights up the screen, and can play a sound.
297    Active,
298    /// The system presents the notification immediately, lights up the screen, and bypasses the mute switch to play a sound.
299    Critical,
300    /// The system adds the notification to the notification list without lighting up the screen or playing a sound.
301    Passive,
302    /// The system presents the notification immediately, lights up the screen, can play a sound, and breaks through system notification controls.
303    TimeSensitive,
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::request::notification::{DefaultNotificationBuilder, NotificationBuilder};
310
311    #[test]
312    fn test_interruption_level_serialization() {
313        let builder = DefaultNotificationBuilder::new()
314            .title("Test Title")
315            .active_interruption_level();
316        let payload = builder.build("test-token", Default::default());
317
318        let json = payload.to_json_string().unwrap();
319        assert!(json.contains("\"interruption-level\":\"active\""));
320
321        let builder = DefaultNotificationBuilder::new()
322            .title("Test Title")
323            .critical_interruption_level();
324        let payload = builder.build("test-token", Default::default());
325
326        let json = payload.to_json_string().unwrap();
327        assert!(json.contains("\"interruption-level\":\"critical\""));
328
329        let builder = DefaultNotificationBuilder::new()
330            .title("Test Title")
331            .passive_interruption_level();
332        let payload = builder.build("test-token", Default::default());
333
334        let json = payload.to_json_string().unwrap();
335        assert!(json.contains("\"interruption-level\":\"passive\""));
336
337        let builder = DefaultNotificationBuilder::new()
338            .title("Test Title")
339            .time_sensitive_interruption_level();
340        let payload = builder.build("test-token", Default::default());
341
342        let json = payload.to_json_string().unwrap();
343        assert!(json.contains("\"interruption-level\":\"time-sensitive\""));
344    }
345
346    #[test]
347    fn test_dismissal_date_serialization() {
348        let builder = DefaultNotificationBuilder::new()
349            .title("Test Title")
350            .dismissal_date(1672531200); // January 1, 2023 00:00:00 UTC
351        let payload = builder.build("test-token", Default::default());
352
353        let json = payload.to_json_string().unwrap();
354        assert!(json.contains("\"dismissal-date\":1672531200"));
355    }
356
357    #[test]
358    fn test_live_activity_payload_serialization() {
359        use serde_json::json;
360
361        let content_state = json!({
362            "currentHealthLevel": 100,
363            "eventDescription": "Adventure has begun!"
364        });
365
366        let attributes = json!({
367            "currentHealthLevel": 100,
368            "eventDescription": "Adventure has begun!"
369        });
370
371        let builder = DefaultNotificationBuilder::new()
372            .timestamp(1234)
373            .event("start")
374            .content_state(&content_state)
375            .attributes_type("AdventureAttributes")
376            .attributes(&attributes)
377            .title("Adventure Alert")
378            .body("Your adventure has started!");
379
380        let payload = builder.build("test-token", Default::default());
381        let json_str = payload.to_json_string().unwrap();
382
383        // Verify all Live Activity fields are correctly serialized
384        assert!(json_str.contains("\"timestamp\":1234"));
385        assert!(json_str.contains("\"event\":\"start\""));
386        assert!(json_str.contains(
387            "\"content-state\":{\"currentHealthLevel\":100,\"eventDescription\":\"Adventure has begun!\"}"
388        ));
389        assert!(json_str.contains("\"attributes-type\":\"AdventureAttributes\""));
390        assert!(json_str.contains(
391            "\"attributes\":{\"currentHealthLevel\":100,\"eventDescription\":\"Adventure has begun!\"}"
392        ));
393
394        // Test iOS 18 channel-based Live Activity
395        let builder = DefaultNotificationBuilder::new()
396            .event("start")
397            .input_push_channel("dHN0LXNyY2gtY2hubA==")
398            .attributes_type("AdventureAttributes")
399            .attributes(&attributes);
400
401        let payload = builder.build("test-token", Default::default());
402        let json_str = payload.to_json_string().unwrap();
403
404        assert!(json_str.contains("\"input-push-channel\":\"dHN0LXNyY2gtY2hubA==\""));
405
406        // Test iOS 18 token-based Live Activity
407        let builder = DefaultNotificationBuilder::new()
408            .event("start")
409            .input_push_token()
410            .attributes_type("AdventureAttributes")
411            .attributes(&attributes);
412
413        let payload = builder.build("test-token", Default::default());
414        let json_str = payload.to_json_string().unwrap();
415
416        assert!(json_str.contains("\"input-push-token\":1"));
417    }
418}