baukit-push 0.10.0

Provider-neutral push delivery with an Expo ticket and receipt adapter, a device registry, and daily delivery claims.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! The provider-neutral push delivery port.

use std::{
    collections::{BTreeMap, HashMap},
    pin::Pin,
    time::Duration,
};

use thiserror::Error;

use crate::{DeviceToken, PushValidationError};

/// Longest provider ticket ID accepted, in bytes.
pub const MAX_PUSH_TICKET_ID_LENGTH: usize = 128;

/// One notification addressed to a single device token.
///
/// `data` travels alongside the visible notification and reaches the app when
/// the user opens it. Keys are ordered so the serialized payload is stable,
/// which keeps request bodies comparable in tests and logs.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PushMessage {
    /// Provider-issued device token the notification is delivered to.
    pub token: DeviceToken,
    /// Notification title shown on the device.
    pub title: String,
    /// Notification body shown on the device.
    pub body: String,
    /// Optional key-value payload delivered with the notification.
    pub data: BTreeMap<String, String>,
    /// Provider channel the notification belongs to, when the platform has channels.
    ///
    /// Android reads this to pick the notification channel. Leave it `None` for
    /// the provider default.
    pub channel_id: Option<String>,
}

impl PushMessage {
    /// Creates a message for a token with a title and body and no extra data.
    pub fn new(token: DeviceToken, title: impl Into<String>, body: impl Into<String>) -> Self {
        Self {
            token,
            title: title.into(),
            body: body.into(),
            data: BTreeMap::new(),
            channel_id: None,
        }
    }

    /// Adds one key-value pair to the notification payload.
    #[must_use]
    pub fn with_data(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.data.insert(key.into(), value.into());
        self
    }

    /// Sets the provider channel the notification belongs to.
    #[must_use]
    pub fn with_channel_id(mut self, channel_id: impl Into<String>) -> Self {
        self.channel_id = Some(channel_id.into());
        self
    }
}

/// Why a provider rejected one notification.
///
/// The variants cover the vocabulary push providers actually return. Anything
/// outside it becomes [`PushRejection::Other`] with the provider's own string,
/// so a new provider code is visible in logs instead of being silently dropped.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PushRejection {
    /// The token no longer belongs to an installed app.
    ///
    /// This is permanent. Delete the token; see [`PushOutcome::is_token_dead`].
    DeviceNotRegistered,
    /// The serialized notification exceeded the provider's size limit.
    MessageTooBig,
    /// Too many notifications were sent to this token in too short a window.
    MessageRateExceeded,
    /// The provider credential was missing, malformed, or rejected.
    InvalidCredentials,
    /// The upstream platform (APNs, FCM) refused the notification.
    ProviderError,
    /// A provider code outside the known vocabulary.
    Other(String),
}

impl PushRejection {
    /// Parses a provider error code into a rejection.
    #[must_use]
    pub fn from_code(code: &str) -> Self {
        match code {
            "DeviceNotRegistered" => Self::DeviceNotRegistered,
            "MessageTooBig" => Self::MessageTooBig,
            "MessageRateExceeded" => Self::MessageRateExceeded,
            "InvalidCredentials" => Self::InvalidCredentials,
            "MismatchSenderId" | "ProviderError" => Self::ProviderError,
            other => Self::Other(other.to_owned()),
        }
    }

    /// Returns whether resending the same notification can succeed.
    ///
    /// Only [`PushRejection::MessageRateExceeded`] and
    /// [`PushRejection::ProviderError`] are worth another attempt. A dead
    /// token, an oversized payload, and a rejected credential all fail the same
    /// way again.
    #[must_use]
    pub const fn is_retryable(&self) -> bool {
        matches!(self, Self::MessageRateExceeded | Self::ProviderError)
    }
}

/// What happened to one notification, keyed by the token it was addressed to.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PushOutcome {
    /// The device token the notification was addressed to.
    pub token: DeviceToken,
    /// The delivery state the provider reported.
    pub status: PushDeliveryStatus,
}

impl PushOutcome {
    /// Returns whether the token should be deleted from the caller's store.
    ///
    /// True only for [`PushRejection::DeviceNotRegistered`]. Every other
    /// rejection describes the notification, not the token.
    #[must_use]
    pub const fn is_token_dead(&self) -> bool {
        matches!(
            &self.status,
            PushDeliveryStatus::Rejected(PushRejection::DeviceNotRegistered)
        )
    }
}

/// The ID a provider issued for one accepted notification.
///
/// Poll the provider's receipt for it through [`PushReceiptSource`].
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PushTicketId(String);

impl PushTicketId {
    /// Validates a ticket ID: 1 to [`MAX_PUSH_TICKET_ID_LENGTH`] bytes of visible ASCII.
    ///
    /// # Errors
    ///
    /// Returns [`PushValidationError::TicketId`] for any other value.
    pub fn new(id: impl Into<String>) -> Result<Self, PushValidationError> {
        let id = id.into();
        let valid = !id.is_empty()
            && id.len() <= MAX_PUSH_TICKET_ID_LENGTH
            && id.bytes().all(|byte| byte.is_ascii_graphic());
        if valid {
            Ok(Self(id))
        } else {
            Err(PushValidationError::TicketId)
        }
    }

    /// Returns the ID for a provider request or a store query.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// The delivery state a provider reported for one notification.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PushDeliveryStatus {
    /// The provider took the notification but has not reported an outcome yet.
    ///
    /// Providers confirm delivery asynchronously. Treat this as neither success
    /// nor failure and do not resend on it. Record the ticket with
    /// [`PendingReceiptStore::record_accepted`](crate::PendingReceiptStore::record_accepted)
    /// to poll its receipt later.
    Accepted(PushTicketId),
    /// The provider handed the notification to the device platform.
    Delivered,
    /// The provider refused the notification.
    Rejected(PushRejection),
}

/// The settled receipt for one accepted notification.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PushReceipt {
    /// The provider handed the notification to the device platform.
    Delivered,
    /// The provider refused the notification after accepting it.
    Rejected(PushRejection),
}

impl PushReceipt {
    /// Returns whether the token the notification went to should be deleted.
    ///
    /// True only for [`PushRejection::DeviceNotRegistered`].
    #[must_use]
    pub const fn is_token_dead(&self) -> bool {
        matches!(self, Self::Rejected(PushRejection::DeviceNotRegistered))
    }
}

impl From<PushReceipt> for PushDeliveryStatus {
    fn from(receipt: PushReceipt) -> Self {
        match receipt {
            PushReceipt::Delivered => Self::Delivered,
            PushReceipt::Rejected(rejection) => Self::Rejected(rejection),
        }
    }
}

/// A failure that prevented the whole batch from being processed.
///
/// Per-notification refusals are not errors; they arrive as
/// [`PushDeliveryStatus::Rejected`] inside a successful result.
#[derive(Clone, Debug, Error, Eq, PartialEq)]
pub enum PushError {
    /// The request never reached the provider, or the provider is failing.
    #[error("push transport failed: {class:?}")]
    Transport {
        /// How the caller should react, including any provider-requested delay.
        class: baukit_http::RetryClass,
    },
    /// The provider answered with a body that does not match its contract.
    #[error("push provider returned an invalid response: {0}")]
    InvalidResponse(String),
}

impl PushError {
    /// Returns whether sending the same batch again can succeed.
    #[must_use]
    pub const fn is_retryable(&self) -> bool {
        match self {
            Self::Transport { class } => class.is_retryable(),
            Self::InvalidResponse(_) => false,
        }
    }

    /// Returns the wait the provider asked for, if it named one.
    #[must_use]
    pub const fn retry_after(&self) -> Option<Duration> {
        match self {
            Self::Transport { class } => class.retry_after(),
            Self::InvalidResponse(_) => None,
        }
    }
}

/// The future returned by [`PushSender::send`].
pub type PushFuture<'a> =
    Pin<Box<dyn Future<Output = Result<Vec<PushOutcome>, PushError>> + Send + 'a>>;

/// Outbound port for delivering notifications to devices.
///
/// Implementations split a batch into provider-sized chunks themselves. The
/// returned outcomes cover every message in the batch, in no guaranteed order;
/// match them to messages by token.
pub trait PushSender: Send + Sync {
    /// Delivers a batch of notifications and reports the outcome of each.
    fn send<'a>(&'a self, batch: Vec<PushMessage>) -> PushFuture<'a>;
}

/// The future returned by [`PushReceiptSource::receipts`].
pub type PushReceiptFuture<'a> = Pin<
    Box<dyn Future<Output = Result<HashMap<PushTicketId, PushReceipt>, PushError>> + Send + 'a>,
>;

/// Outbound port for reading the receipts of accepted notifications.
///
/// Implementations split the IDs into provider-sized requests themselves.
pub trait PushReceiptSource: Send + Sync {
    /// Fetches the settled receipts for the given tickets.
    ///
    /// A ticket the provider has not settled yet is absent from the map.
    fn receipts<'a>(&'a self, tickets: Vec<PushTicketId>) -> PushReceiptFuture<'a>;
}

#[cfg(test)]
mod tests {
    use super::*;

    fn token() -> DeviceToken {
        DeviceToken::new("t").expect("valid test token")
    }

    #[test]
    fn known_provider_codes_map_onto_the_neutral_vocabulary() {
        assert_eq!(
            PushRejection::from_code("DeviceNotRegistered"),
            PushRejection::DeviceNotRegistered
        );
        assert_eq!(
            PushRejection::from_code("MessageTooBig"),
            PushRejection::MessageTooBig
        );
        assert_eq!(
            PushRejection::from_code("MessageRateExceeded"),
            PushRejection::MessageRateExceeded
        );
        assert_eq!(
            PushRejection::from_code("InvalidCredentials"),
            PushRejection::InvalidCredentials
        );
        assert_eq!(
            PushRejection::from_code("MismatchSenderId"),
            PushRejection::ProviderError
        );
    }

    #[test]
    fn an_unknown_code_is_preserved_verbatim() {
        assert_eq!(
            PushRejection::from_code("SomeNewExpoCode"),
            PushRejection::Other("SomeNewExpoCode".to_owned())
        );
    }

    #[test]
    fn only_rate_limits_and_provider_faults_are_worth_resending() {
        assert!(PushRejection::MessageRateExceeded.is_retryable());
        assert!(PushRejection::ProviderError.is_retryable());
        assert!(!PushRejection::DeviceNotRegistered.is_retryable());
        assert!(!PushRejection::MessageTooBig.is_retryable());
        assert!(!PushRejection::InvalidCredentials.is_retryable());
        assert!(!PushRejection::Other("x".to_owned()).is_retryable());
    }

    #[test]
    fn only_an_unregistered_device_marks_a_token_dead() {
        let dead = PushOutcome {
            token: token(),
            status: PushDeliveryStatus::Rejected(PushRejection::DeviceNotRegistered),
        };
        assert!(dead.is_token_dead());
        for status in [
            PushDeliveryStatus::Accepted(PushTicketId::new("ticket").expect("valid ticket")),
            PushDeliveryStatus::Delivered,
            PushDeliveryStatus::Rejected(PushRejection::MessageTooBig),
        ] {
            let outcome = PushOutcome {
                token: token(),
                status,
            };
            assert!(!outcome.is_token_dead());
        }
    }

    #[test]
    fn an_outcome_never_shows_its_token_in_debug_output() {
        let outcome = PushOutcome {
            token: DeviceToken::new("ExponentPushToken[secret]").expect("valid token"),
            status: PushDeliveryStatus::Delivered,
        };
        let message = PushMessage::new(outcome.token.clone(), "Title", "Body");
        assert!(!format!("{outcome:?} {message:?}").contains("secret"));
    }

    #[test]
    fn transport_errors_carry_the_providers_retry_delay() {
        let error = PushError::Transport {
            class: baukit_http::RetryClass::RetryAfter(Duration::from_secs(30)),
        };
        assert!(error.is_retryable());
        assert_eq!(error.retry_after(), Some(Duration::from_secs(30)));

        let invalid = PushError::InvalidResponse("no data field".to_owned());
        assert!(!invalid.is_retryable());
        assert_eq!(invalid.retry_after(), None);
    }

    #[test]
    fn a_message_builds_with_data_and_a_channel() {
        let message = PushMessage::new(token(), "Title", "Body")
            .with_data("session_id", "abc")
            .with_channel_id("reminders");
        assert_eq!(message.data["session_id"], "abc");
        assert_eq!(message.channel_id.as_deref(), Some("reminders"));
    }

    #[test]
    fn a_ticket_id_accepts_visible_ascii_up_to_the_limit() {
        assert!(PushTicketId::new("019285a1-7c3e-7d2a-9f1b-3c4d5e6f7a8b").is_ok());
        assert!(PushTicketId::new("x".repeat(MAX_PUSH_TICKET_ID_LENGTH)).is_ok());
        for invalid in [
            String::new(),
            "x".repeat(MAX_PUSH_TICKET_ID_LENGTH + 1),
            "with space".to_owned(),
        ] {
            assert_eq!(
                PushTicketId::new(invalid),
                Err(PushValidationError::TicketId)
            );
        }
    }

    #[test]
    fn only_an_unregistered_device_receipt_marks_a_token_dead() {
        assert!(PushReceipt::Rejected(PushRejection::DeviceNotRegistered).is_token_dead());
        assert!(!PushReceipt::Rejected(PushRejection::MessageTooBig).is_token_dead());
        assert!(!PushReceipt::Delivered.is_token_dead());
        assert_eq!(
            PushDeliveryStatus::from(PushReceipt::Delivered),
            PushDeliveryStatus::Delivered
        );
    }
}