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}