1use std::{
4 collections::{BTreeMap, HashMap},
5 pin::Pin,
6 time::Duration,
7};
8
9use thiserror::Error;
10
11use crate::{DeviceToken, PushValidationError};
12
13pub const MAX_PUSH_TICKET_ID_LENGTH: usize = 128;
15
16#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct PushMessage {
23 pub token: DeviceToken,
25 pub title: String,
27 pub body: String,
29 pub data: BTreeMap<String, String>,
31 pub channel_id: Option<String>,
36}
37
38impl PushMessage {
39 pub fn new(token: DeviceToken, title: impl Into<String>, body: impl Into<String>) -> Self {
41 Self {
42 token,
43 title: title.into(),
44 body: body.into(),
45 data: BTreeMap::new(),
46 channel_id: None,
47 }
48 }
49
50 #[must_use]
52 pub fn with_data(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
53 self.data.insert(key.into(), value.into());
54 self
55 }
56
57 #[must_use]
59 pub fn with_channel_id(mut self, channel_id: impl Into<String>) -> Self {
60 self.channel_id = Some(channel_id.into());
61 self
62 }
63}
64
65#[derive(Clone, Debug, Eq, PartialEq)]
71pub enum PushRejection {
72 DeviceNotRegistered,
76 MessageTooBig,
78 MessageRateExceeded,
80 InvalidCredentials,
82 ProviderError,
84 Other(String),
86}
87
88impl PushRejection {
89 #[must_use]
91 pub fn from_code(code: &str) -> Self {
92 match code {
93 "DeviceNotRegistered" => Self::DeviceNotRegistered,
94 "MessageTooBig" => Self::MessageTooBig,
95 "MessageRateExceeded" => Self::MessageRateExceeded,
96 "InvalidCredentials" => Self::InvalidCredentials,
97 "MismatchSenderId" | "ProviderError" => Self::ProviderError,
98 other => Self::Other(other.to_owned()),
99 }
100 }
101
102 #[must_use]
109 pub const fn is_retryable(&self) -> bool {
110 matches!(self, Self::MessageRateExceeded | Self::ProviderError)
111 }
112}
113
114#[derive(Clone, Debug, Eq, PartialEq)]
116pub struct PushOutcome {
117 pub token: DeviceToken,
119 pub status: PushDeliveryStatus,
121}
122
123impl PushOutcome {
124 #[must_use]
129 pub const fn is_token_dead(&self) -> bool {
130 matches!(
131 &self.status,
132 PushDeliveryStatus::Rejected(PushRejection::DeviceNotRegistered)
133 )
134 }
135}
136
137#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
141pub struct PushTicketId(String);
142
143impl PushTicketId {
144 pub fn new(id: impl Into<String>) -> Result<Self, PushValidationError> {
150 let id = id.into();
151 let valid = !id.is_empty()
152 && id.len() <= MAX_PUSH_TICKET_ID_LENGTH
153 && id.bytes().all(|byte| byte.is_ascii_graphic());
154 if valid {
155 Ok(Self(id))
156 } else {
157 Err(PushValidationError::TicketId)
158 }
159 }
160
161 #[must_use]
163 pub fn as_str(&self) -> &str {
164 &self.0
165 }
166}
167
168#[derive(Clone, Debug, Eq, PartialEq)]
170pub enum PushDeliveryStatus {
171 Accepted(PushTicketId),
178 Delivered,
180 Rejected(PushRejection),
182}
183
184#[derive(Clone, Debug, Eq, PartialEq)]
186pub enum PushReceipt {
187 Delivered,
189 Rejected(PushRejection),
191}
192
193impl PushReceipt {
194 #[must_use]
198 pub const fn is_token_dead(&self) -> bool {
199 matches!(self, Self::Rejected(PushRejection::DeviceNotRegistered))
200 }
201}
202
203impl From<PushReceipt> for PushDeliveryStatus {
204 fn from(receipt: PushReceipt) -> Self {
205 match receipt {
206 PushReceipt::Delivered => Self::Delivered,
207 PushReceipt::Rejected(rejection) => Self::Rejected(rejection),
208 }
209 }
210}
211
212#[derive(Clone, Debug, Error, Eq, PartialEq)]
217pub enum PushError {
218 #[error("push transport failed: {class:?}")]
220 Transport {
221 class: baukit_http::RetryClass,
223 },
224 #[error("push provider returned an invalid response: {0}")]
226 InvalidResponse(String),
227}
228
229impl PushError {
230 #[must_use]
232 pub const fn is_retryable(&self) -> bool {
233 match self {
234 Self::Transport { class } => class.is_retryable(),
235 Self::InvalidResponse(_) => false,
236 }
237 }
238
239 #[must_use]
241 pub const fn retry_after(&self) -> Option<Duration> {
242 match self {
243 Self::Transport { class } => class.retry_after(),
244 Self::InvalidResponse(_) => None,
245 }
246 }
247}
248
249pub type PushFuture<'a> =
251 Pin<Box<dyn Future<Output = Result<Vec<PushOutcome>, PushError>> + Send + 'a>>;
252
253pub trait PushSender: Send + Sync {
259 fn send<'a>(&'a self, batch: Vec<PushMessage>) -> PushFuture<'a>;
261}
262
263pub type PushReceiptFuture<'a> = Pin<
265 Box<dyn Future<Output = Result<HashMap<PushTicketId, PushReceipt>, PushError>> + Send + 'a>,
266>;
267
268pub trait PushReceiptSource: Send + Sync {
272 fn receipts<'a>(&'a self, tickets: Vec<PushTicketId>) -> PushReceiptFuture<'a>;
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 fn token() -> DeviceToken {
283 DeviceToken::new("t").expect("valid test token")
284 }
285
286 #[test]
287 fn known_provider_codes_map_onto_the_neutral_vocabulary() {
288 assert_eq!(
289 PushRejection::from_code("DeviceNotRegistered"),
290 PushRejection::DeviceNotRegistered
291 );
292 assert_eq!(
293 PushRejection::from_code("MessageTooBig"),
294 PushRejection::MessageTooBig
295 );
296 assert_eq!(
297 PushRejection::from_code("MessageRateExceeded"),
298 PushRejection::MessageRateExceeded
299 );
300 assert_eq!(
301 PushRejection::from_code("InvalidCredentials"),
302 PushRejection::InvalidCredentials
303 );
304 assert_eq!(
305 PushRejection::from_code("MismatchSenderId"),
306 PushRejection::ProviderError
307 );
308 }
309
310 #[test]
311 fn an_unknown_code_is_preserved_verbatim() {
312 assert_eq!(
313 PushRejection::from_code("SomeNewExpoCode"),
314 PushRejection::Other("SomeNewExpoCode".to_owned())
315 );
316 }
317
318 #[test]
319 fn only_rate_limits_and_provider_faults_are_worth_resending() {
320 assert!(PushRejection::MessageRateExceeded.is_retryable());
321 assert!(PushRejection::ProviderError.is_retryable());
322 assert!(!PushRejection::DeviceNotRegistered.is_retryable());
323 assert!(!PushRejection::MessageTooBig.is_retryable());
324 assert!(!PushRejection::InvalidCredentials.is_retryable());
325 assert!(!PushRejection::Other("x".to_owned()).is_retryable());
326 }
327
328 #[test]
329 fn only_an_unregistered_device_marks_a_token_dead() {
330 let dead = PushOutcome {
331 token: token(),
332 status: PushDeliveryStatus::Rejected(PushRejection::DeviceNotRegistered),
333 };
334 assert!(dead.is_token_dead());
335 for status in [
336 PushDeliveryStatus::Accepted(PushTicketId::new("ticket").expect("valid ticket")),
337 PushDeliveryStatus::Delivered,
338 PushDeliveryStatus::Rejected(PushRejection::MessageTooBig),
339 ] {
340 let outcome = PushOutcome {
341 token: token(),
342 status,
343 };
344 assert!(!outcome.is_token_dead());
345 }
346 }
347
348 #[test]
349 fn an_outcome_never_shows_its_token_in_debug_output() {
350 let outcome = PushOutcome {
351 token: DeviceToken::new("ExponentPushToken[secret]").expect("valid token"),
352 status: PushDeliveryStatus::Delivered,
353 };
354 let message = PushMessage::new(outcome.token.clone(), "Title", "Body");
355 assert!(!format!("{outcome:?} {message:?}").contains("secret"));
356 }
357
358 #[test]
359 fn transport_errors_carry_the_providers_retry_delay() {
360 let error = PushError::Transport {
361 class: baukit_http::RetryClass::RetryAfter(Duration::from_secs(30)),
362 };
363 assert!(error.is_retryable());
364 assert_eq!(error.retry_after(), Some(Duration::from_secs(30)));
365
366 let invalid = PushError::InvalidResponse("no data field".to_owned());
367 assert!(!invalid.is_retryable());
368 assert_eq!(invalid.retry_after(), None);
369 }
370
371 #[test]
372 fn a_message_builds_with_data_and_a_channel() {
373 let message = PushMessage::new(token(), "Title", "Body")
374 .with_data("session_id", "abc")
375 .with_channel_id("reminders");
376 assert_eq!(message.data["session_id"], "abc");
377 assert_eq!(message.channel_id.as_deref(), Some("reminders"));
378 }
379
380 #[test]
381 fn a_ticket_id_accepts_visible_ascii_up_to_the_limit() {
382 assert!(PushTicketId::new("019285a1-7c3e-7d2a-9f1b-3c4d5e6f7a8b").is_ok());
383 assert!(PushTicketId::new("x".repeat(MAX_PUSH_TICKET_ID_LENGTH)).is_ok());
384 for invalid in [
385 String::new(),
386 "x".repeat(MAX_PUSH_TICKET_ID_LENGTH + 1),
387 "with space".to_owned(),
388 ] {
389 assert_eq!(
390 PushTicketId::new(invalid),
391 Err(PushValidationError::TicketId)
392 );
393 }
394 }
395
396 #[test]
397 fn only_an_unregistered_device_receipt_marks_a_token_dead() {
398 assert!(PushReceipt::Rejected(PushRejection::DeviceNotRegistered).is_token_dead());
399 assert!(!PushReceipt::Rejected(PushRejection::MessageTooBig).is_token_dead());
400 assert!(!PushReceipt::Delivered.is_token_dead());
401 assert_eq!(
402 PushDeliveryStatus::from(PushReceipt::Delivered),
403 PushDeliveryStatus::Delivered
404 );
405 }
406}