Skip to main content

baukit_push/
port.rs

1//! The provider-neutral push delivery port.
2
3use std::{collections::BTreeMap, pin::Pin, time::Duration};
4
5use thiserror::Error;
6
7/// One notification addressed to a single device token.
8///
9/// `data` travels alongside the visible notification and reaches the app when
10/// the user opens it. Keys are ordered so the serialized payload is stable,
11/// which keeps request bodies comparable in tests and logs.
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct PushMessage {
14    /// Provider-issued device token the notification is delivered to.
15    pub token: String,
16    /// Notification title shown on the device.
17    pub title: String,
18    /// Notification body shown on the device.
19    pub body: String,
20    /// Optional key-value payload delivered with the notification.
21    pub data: BTreeMap<String, String>,
22    /// Provider channel the notification belongs to, when the platform has channels.
23    ///
24    /// Android reads this to pick the notification channel. Leave it `None` for
25    /// the provider default.
26    pub channel_id: Option<String>,
27}
28
29impl PushMessage {
30    /// Creates a message for a token with a title and body and no extra data.
31    pub fn new(
32        token: impl Into<String>,
33        title: impl Into<String>,
34        body: impl Into<String>,
35    ) -> Self {
36        Self {
37            token: token.into(),
38            title: title.into(),
39            body: body.into(),
40            data: BTreeMap::new(),
41            channel_id: None,
42        }
43    }
44
45    /// Adds one key-value pair to the notification payload.
46    #[must_use]
47    pub fn with_data(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
48        self.data.insert(key.into(), value.into());
49        self
50    }
51
52    /// Sets the provider channel the notification belongs to.
53    #[must_use]
54    pub fn with_channel_id(mut self, channel_id: impl Into<String>) -> Self {
55        self.channel_id = Some(channel_id.into());
56        self
57    }
58}
59
60/// Why a provider rejected one notification.
61///
62/// The variants cover the vocabulary push providers actually return. Anything
63/// outside it becomes [`PushRejection::Other`] with the provider's own string,
64/// so a new provider code is visible in logs instead of being silently dropped.
65#[derive(Clone, Debug, Eq, PartialEq)]
66pub enum PushRejection {
67    /// The token no longer belongs to an installed app.
68    ///
69    /// This is permanent. Delete the token; see [`PushOutcome::is_token_dead`].
70    DeviceNotRegistered,
71    /// The serialized notification exceeded the provider's size limit.
72    MessageTooBig,
73    /// Too many notifications were sent to this token in too short a window.
74    MessageRateExceeded,
75    /// The provider credential was missing, malformed, or rejected.
76    InvalidCredentials,
77    /// The upstream platform (APNs, FCM) refused the notification.
78    ProviderError,
79    /// A provider code outside the known vocabulary.
80    Other(String),
81}
82
83impl PushRejection {
84    /// Parses a provider error code into a rejection.
85    #[must_use]
86    pub fn from_code(code: &str) -> Self {
87        match code {
88            "DeviceNotRegistered" => Self::DeviceNotRegistered,
89            "MessageTooBig" => Self::MessageTooBig,
90            "MessageRateExceeded" => Self::MessageRateExceeded,
91            "InvalidCredentials" => Self::InvalidCredentials,
92            "MismatchSenderId" | "ProviderError" => Self::ProviderError,
93            other => Self::Other(other.to_owned()),
94        }
95    }
96
97    /// Returns whether resending the same notification can succeed.
98    ///
99    /// Only [`PushRejection::MessageRateExceeded`] and
100    /// [`PushRejection::ProviderError`] are worth another attempt. A dead
101    /// token, an oversized payload, and a rejected credential all fail the same
102    /// way again.
103    #[must_use]
104    pub const fn is_retryable(&self) -> bool {
105        matches!(self, Self::MessageRateExceeded | Self::ProviderError)
106    }
107}
108
109/// What happened to one notification, keyed by the token it was addressed to.
110#[derive(Clone, Debug, Eq, PartialEq)]
111pub struct PushOutcome {
112    /// The device token the notification was addressed to.
113    pub token: String,
114    /// The delivery state the provider reported.
115    pub status: PushDeliveryStatus,
116}
117
118impl PushOutcome {
119    /// Returns whether the token should be deleted from the caller's store.
120    ///
121    /// True only for [`PushRejection::DeviceNotRegistered`]. Every other
122    /// rejection describes the notification, not the token.
123    #[must_use]
124    pub const fn is_token_dead(&self) -> bool {
125        matches!(
126            &self.status,
127            PushDeliveryStatus::Rejected(PushRejection::DeviceNotRegistered)
128        )
129    }
130}
131
132/// The delivery state a provider reported for one notification.
133#[derive(Clone, Debug, Eq, PartialEq)]
134pub enum PushDeliveryStatus {
135    /// The provider took the notification but has not reported an outcome yet.
136    ///
137    /// Providers confirm delivery asynchronously. Treat this as neither success
138    /// nor failure and do not resend on it.
139    Accepted,
140    /// The provider handed the notification to the device platform.
141    Delivered,
142    /// The provider refused the notification.
143    Rejected(PushRejection),
144}
145
146/// A failure that prevented the whole batch from being processed.
147///
148/// Per-notification refusals are not errors; they arrive as
149/// [`PushDeliveryStatus::Rejected`] inside a successful result.
150#[derive(Clone, Debug, Error, Eq, PartialEq)]
151pub enum PushError {
152    /// The request never reached the provider, or the provider is failing.
153    #[error("push transport failed: {class:?}")]
154    Transport {
155        /// How the caller should react, including any provider-requested delay.
156        class: baukit_http::RetryClass,
157    },
158    /// The provider answered with a body that does not match its contract.
159    #[error("push provider returned an invalid response: {0}")]
160    InvalidResponse(String),
161}
162
163impl PushError {
164    /// Returns whether sending the same batch again can succeed.
165    #[must_use]
166    pub const fn is_retryable(&self) -> bool {
167        match self {
168            Self::Transport { class } => class.is_retryable(),
169            Self::InvalidResponse(_) => false,
170        }
171    }
172
173    /// Returns the wait the provider asked for, if it named one.
174    #[must_use]
175    pub const fn retry_after(&self) -> Option<Duration> {
176        match self {
177            Self::Transport { class } => class.retry_after(),
178            Self::InvalidResponse(_) => None,
179        }
180    }
181}
182
183/// The future returned by [`PushSender::send`].
184pub type PushFuture<'a> =
185    Pin<Box<dyn Future<Output = Result<Vec<PushOutcome>, PushError>> + Send + 'a>>;
186
187/// Outbound port for delivering notifications to devices.
188///
189/// Implementations split a batch into provider-sized chunks themselves. The
190/// returned outcomes cover every message in the batch, in no guaranteed order;
191/// match them to messages by token.
192pub trait PushSender: Send + Sync {
193    /// Delivers a batch of notifications and reports the outcome of each.
194    fn send<'a>(&'a self, batch: Vec<PushMessage>) -> PushFuture<'a>;
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn known_provider_codes_map_onto_the_neutral_vocabulary() {
203        assert_eq!(
204            PushRejection::from_code("DeviceNotRegistered"),
205            PushRejection::DeviceNotRegistered
206        );
207        assert_eq!(
208            PushRejection::from_code("MessageTooBig"),
209            PushRejection::MessageTooBig
210        );
211        assert_eq!(
212            PushRejection::from_code("MessageRateExceeded"),
213            PushRejection::MessageRateExceeded
214        );
215        assert_eq!(
216            PushRejection::from_code("InvalidCredentials"),
217            PushRejection::InvalidCredentials
218        );
219        assert_eq!(
220            PushRejection::from_code("MismatchSenderId"),
221            PushRejection::ProviderError
222        );
223    }
224
225    #[test]
226    fn an_unknown_code_is_preserved_verbatim() {
227        assert_eq!(
228            PushRejection::from_code("SomeNewExpoCode"),
229            PushRejection::Other("SomeNewExpoCode".to_owned())
230        );
231    }
232
233    #[test]
234    fn only_rate_limits_and_provider_faults_are_worth_resending() {
235        assert!(PushRejection::MessageRateExceeded.is_retryable());
236        assert!(PushRejection::ProviderError.is_retryable());
237        assert!(!PushRejection::DeviceNotRegistered.is_retryable());
238        assert!(!PushRejection::MessageTooBig.is_retryable());
239        assert!(!PushRejection::InvalidCredentials.is_retryable());
240        assert!(!PushRejection::Other("x".to_owned()).is_retryable());
241    }
242
243    #[test]
244    fn only_an_unregistered_device_marks_a_token_dead() {
245        let dead = PushOutcome {
246            token: "t".to_owned(),
247            status: PushDeliveryStatus::Rejected(PushRejection::DeviceNotRegistered),
248        };
249        assert!(dead.is_token_dead());
250        for status in [
251            PushDeliveryStatus::Accepted,
252            PushDeliveryStatus::Delivered,
253            PushDeliveryStatus::Rejected(PushRejection::MessageTooBig),
254        ] {
255            let outcome = PushOutcome {
256                token: "t".to_owned(),
257                status,
258            };
259            assert!(!outcome.is_token_dead());
260        }
261    }
262
263    #[test]
264    fn transport_errors_carry_the_providers_retry_delay() {
265        let error = PushError::Transport {
266            class: baukit_http::RetryClass::RetryAfter(Duration::from_secs(30)),
267        };
268        assert!(error.is_retryable());
269        assert_eq!(error.retry_after(), Some(Duration::from_secs(30)));
270
271        let invalid = PushError::InvalidResponse("no data field".to_owned());
272        assert!(!invalid.is_retryable());
273        assert_eq!(invalid.retry_after(), None);
274    }
275
276    #[test]
277    fn a_message_builds_with_data_and_a_channel() {
278        let message = PushMessage::new("token", "Title", "Body")
279            .with_data("session_id", "abc")
280            .with_channel_id("reminders");
281        assert_eq!(message.data["session_id"], "abc");
282        assert_eq!(message.channel_id.as_deref(), Some("reminders"));
283    }
284}