1use std::{collections::BTreeMap, pin::Pin, time::Duration};
4
5use thiserror::Error;
6
7#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct PushMessage {
14 pub token: String,
16 pub title: String,
18 pub body: String,
20 pub data: BTreeMap<String, String>,
22 pub channel_id: Option<String>,
27}
28
29impl PushMessage {
30 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 #[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 #[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#[derive(Clone, Debug, Eq, PartialEq)]
66pub enum PushRejection {
67 DeviceNotRegistered,
71 MessageTooBig,
73 MessageRateExceeded,
75 InvalidCredentials,
77 ProviderError,
79 Other(String),
81}
82
83impl PushRejection {
84 #[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 #[must_use]
104 pub const fn is_retryable(&self) -> bool {
105 matches!(self, Self::MessageRateExceeded | Self::ProviderError)
106 }
107}
108
109#[derive(Clone, Debug, Eq, PartialEq)]
111pub struct PushOutcome {
112 pub token: String,
114 pub status: PushDeliveryStatus,
116}
117
118impl PushOutcome {
119 #[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#[derive(Clone, Debug, Eq, PartialEq)]
134pub enum PushDeliveryStatus {
135 Accepted,
140 Delivered,
142 Rejected(PushRejection),
144}
145
146#[derive(Clone, Debug, Error, Eq, PartialEq)]
151pub enum PushError {
152 #[error("push transport failed: {class:?}")]
154 Transport {
155 class: baukit_http::RetryClass,
157 },
158 #[error("push provider returned an invalid response: {0}")]
160 InvalidResponse(String),
161}
162
163impl PushError {
164 #[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 #[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
183pub type PushFuture<'a> =
185 Pin<Box<dyn Future<Output = Result<Vec<PushOutcome>, PushError>> + Send + 'a>>;
186
187pub trait PushSender: Send + Sync {
193 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}