use std::{collections::BTreeMap, pin::Pin, time::Duration};
use thiserror::Error;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PushMessage {
pub token: String,
pub title: String,
pub body: String,
pub data: BTreeMap<String, String>,
pub channel_id: Option<String>,
}
impl PushMessage {
pub fn new(
token: impl Into<String>,
title: impl Into<String>,
body: impl Into<String>,
) -> Self {
Self {
token: token.into(),
title: title.into(),
body: body.into(),
data: BTreeMap::new(),
channel_id: None,
}
}
#[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
}
#[must_use]
pub fn with_channel_id(mut self, channel_id: impl Into<String>) -> Self {
self.channel_id = Some(channel_id.into());
self
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PushRejection {
DeviceNotRegistered,
MessageTooBig,
MessageRateExceeded,
InvalidCredentials,
ProviderError,
Other(String),
}
impl PushRejection {
#[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()),
}
}
#[must_use]
pub const fn is_retryable(&self) -> bool {
matches!(self, Self::MessageRateExceeded | Self::ProviderError)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PushOutcome {
pub token: String,
pub status: PushDeliveryStatus,
}
impl PushOutcome {
#[must_use]
pub const fn is_token_dead(&self) -> bool {
matches!(
&self.status,
PushDeliveryStatus::Rejected(PushRejection::DeviceNotRegistered)
)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PushDeliveryStatus {
Accepted,
Delivered,
Rejected(PushRejection),
}
#[derive(Clone, Debug, Error, Eq, PartialEq)]
pub enum PushError {
#[error("push transport failed: {class:?}")]
Transport {
class: baukit_http::RetryClass,
},
#[error("push provider returned an invalid response: {0}")]
InvalidResponse(String),
}
impl PushError {
#[must_use]
pub const fn is_retryable(&self) -> bool {
match self {
Self::Transport { class } => class.is_retryable(),
Self::InvalidResponse(_) => false,
}
}
#[must_use]
pub const fn retry_after(&self) -> Option<Duration> {
match self {
Self::Transport { class } => class.retry_after(),
Self::InvalidResponse(_) => None,
}
}
}
pub type PushFuture<'a> =
Pin<Box<dyn Future<Output = Result<Vec<PushOutcome>, PushError>> + Send + 'a>>;
pub trait PushSender: Send + Sync {
fn send<'a>(&'a self, batch: Vec<PushMessage>) -> PushFuture<'a>;
}
#[cfg(test)]
mod tests {
use super::*;
#[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: "t".to_owned(),
status: PushDeliveryStatus::Rejected(PushRejection::DeviceNotRegistered),
};
assert!(dead.is_token_dead());
for status in [
PushDeliveryStatus::Accepted,
PushDeliveryStatus::Delivered,
PushDeliveryStatus::Rejected(PushRejection::MessageTooBig),
] {
let outcome = PushOutcome {
token: "t".to_owned(),
status,
};
assert!(!outcome.is_token_dead());
}
}
#[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"));
}
}