use std::{
collections::{BTreeMap, HashMap},
pin::Pin,
time::Duration,
};
use thiserror::Error;
use crate::{DeviceToken, PushValidationError};
pub const MAX_PUSH_TICKET_ID_LENGTH: usize = 128;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PushMessage {
pub token: DeviceToken,
pub title: String,
pub body: String,
pub data: BTreeMap<String, String>,
pub channel_id: Option<String>,
}
impl PushMessage {
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,
}
}
#[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: DeviceToken,
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, Hash, Ord, PartialEq, PartialOrd)]
pub struct PushTicketId(String);
impl PushTicketId {
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)
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PushDeliveryStatus {
Accepted(PushTicketId),
Delivered,
Rejected(PushRejection),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PushReceipt {
Delivered,
Rejected(PushRejection),
}
impl PushReceipt {
#[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),
}
}
}
#[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>;
}
pub type PushReceiptFuture<'a> = Pin<
Box<dyn Future<Output = Result<HashMap<PushTicketId, PushReceipt>, PushError>> + Send + 'a>,
>;
pub trait PushReceiptSource: Send + Sync {
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
);
}
}