Skip to main content

baukit_push/
expo.rs

1//! Expo push adapter implementing the ticket then receipt protocol.
2
3use std::collections::{BTreeMap, HashMap};
4
5use axum::http::{HeaderMap, StatusCode};
6use baukit_http::{classify_http_status, classify_transport_error};
7use serde::{Deserialize, Serialize};
8
9use crate::{
10    PushDeliveryStatus, PushError, PushFuture, PushMessage, PushOptions, PushOptionsError,
11    PushOutcome, PushRejection, PushSender,
12};
13
14const OK_STATUS: &str = "ok";
15
16/// Expo push adapter.
17///
18/// Expo answers `/push/send` with one *ticket* per notification. A ticket only
19/// says Expo accepted the notification; delivery is confirmed later through
20/// `/push/getReceipts`. This adapter runs both phases per batch, so one
21/// [`PushSender::send`] call returns settled outcomes wherever Expo has them and
22/// [`PushDeliveryStatus::Accepted`] for the receipts that are not ready yet.
23///
24/// Cloning is cheap; the inner `reqwest` client shares its connection pool.
25#[derive(Clone, Debug)]
26pub struct ExpoPushSender {
27    client: reqwest::Client,
28    options: PushOptions,
29}
30
31#[derive(Serialize)]
32#[serde(rename_all = "camelCase")]
33struct ExpoMessage<'a> {
34    to: &'a str,
35    title: &'a str,
36    body: &'a str,
37    sound: &'static str,
38    data: &'a BTreeMap<String, String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    channel_id: Option<&'a str>,
41}
42
43#[derive(Deserialize)]
44struct ExpoTicketResponse {
45    data: Option<Vec<ExpoTicket>>,
46}
47
48#[derive(Deserialize)]
49struct ExpoTicket {
50    status: String,
51    id: Option<String>,
52    details: Option<ExpoErrorDetails>,
53}
54
55#[derive(Deserialize)]
56struct ExpoReceiptResponse {
57    data: Option<HashMap<String, ExpoReceipt>>,
58}
59
60#[derive(Deserialize)]
61struct ExpoReceipt {
62    status: String,
63    details: Option<ExpoErrorDetails>,
64}
65
66#[derive(Deserialize)]
67struct ExpoErrorDetails {
68    error: Option<String>,
69}
70
71impl ExpoPushSender {
72    /// Builds a sender against the public Expo API with default options.
73    pub fn new() -> Result<Self, PushOptionsError> {
74        Self::with_options(PushOptions::default())
75    }
76
77    /// Builds a sender from validated options.
78    pub fn with_options(options: PushOptions) -> Result<Self, PushOptionsError> {
79        let client = reqwest::Client::builder()
80            .timeout(options.request_timeout())
81            .build()
82            .map_err(|error| PushOptionsError::InvalidEndpoint(error.to_string()))?;
83        Ok(Self { client, options })
84    }
85
86    /// Returns the options this sender was built from.
87    #[must_use]
88    pub const fn options(&self) -> &PushOptions {
89        &self.options
90    }
91
92    async fn send_chunk(&self, messages: &[PushMessage]) -> Result<Vec<PushOutcome>, PushError> {
93        let payload = messages.iter().map(expo_message).collect::<Vec<_>>();
94        let response: ExpoTicketResponse = self.post(self.options.endpoint(), &payload).await?;
95        let tickets = response
96            .data
97            .ok_or_else(|| PushError::InvalidResponse("ticket response has no data".to_owned()))?;
98        if tickets.len() != messages.len() {
99            return Err(PushError::InvalidResponse(format!(
100                "expected {} tickets, got {}",
101                messages.len(),
102                tickets.len()
103            )));
104        }
105
106        let mut outcomes = Vec::with_capacity(messages.len());
107        let mut pending = HashMap::new();
108        for (message, ticket) in messages.iter().zip(tickets) {
109            match rejection(ticket.details.as_ref()) {
110                Some(rejection) => outcomes.push(PushOutcome {
111                    token: message.token.clone(),
112                    status: PushDeliveryStatus::Rejected(rejection),
113                }),
114                None if ticket.status == OK_STATUS => {
115                    let id = ticket.id.ok_or_else(|| {
116                        PushError::InvalidResponse("accepted ticket has no id".to_owned())
117                    })?;
118                    pending.insert(id, message.token.clone());
119                }
120                None => outcomes.push(PushOutcome {
121                    token: message.token.clone(),
122                    status: PushDeliveryStatus::Rejected(PushRejection::ProviderError),
123                }),
124            }
125        }
126        if pending.is_empty() {
127            return Ok(outcomes);
128        }
129
130        let ids = pending.keys().cloned().collect::<Vec<_>>();
131        let response: ExpoReceiptResponse = self
132            .post(
133                self.options.receipts_endpoint(),
134                &serde_json::json!({ "ids": ids }),
135            )
136            .await?;
137        let receipts = response.data.unwrap_or_default();
138        outcomes.extend(pending.into_iter().map(|(id, token)| PushOutcome {
139            token,
140            status: receipts.get(&id).map_or(
141                // Expo has not settled this notification yet. Callers must not
142                // resend on it; the next receipt poll or a later batch reports
143                // the final state.
144                PushDeliveryStatus::Accepted,
145                |receipt| match rejection(receipt.details.as_ref()) {
146                    Some(rejection) => PushDeliveryStatus::Rejected(rejection),
147                    None if receipt.status == OK_STATUS => PushDeliveryStatus::Delivered,
148                    None => PushDeliveryStatus::Rejected(PushRejection::ProviderError),
149                },
150            ),
151        }));
152        Ok(outcomes)
153    }
154
155    async fn post<T: serde::de::DeserializeOwned>(
156        &self,
157        endpoint: &str,
158        payload: &impl Serialize,
159    ) -> Result<T, PushError> {
160        let mut request = self.client.post(endpoint).json(payload);
161        if let Some(token) = self.options.access_token() {
162            request = request.bearer_auth(token);
163        }
164        let response = request.send().await.map_err(|error| {
165            tracing::debug!(target: "baukit_push", %error, endpoint, "expo request failed");
166            PushError::Transport {
167                class: classify_transport_error(error.is_timeout()),
168            }
169        })?;
170        let status = response.status();
171        if !status.is_success() {
172            return Err(transport_error(status, response.headers()));
173        }
174        response.json::<T>().await.map_err(|error| {
175            PushError::InvalidResponse(format!("{endpoint} response did not parse: {error}"))
176        })
177    }
178}
179
180fn transport_error(status: StatusCode, headers: &HeaderMap) -> PushError {
181    // Expo rejects a whole request for a bad token or a rate limit, so a 4xx
182    // that is not one of those still classifies as permanent and the caller
183    // learns not to retry it.
184    let class = classify_http_status(status, headers, &[]);
185    PushError::Transport { class }
186}
187
188fn expo_message(message: &PushMessage) -> ExpoMessage<'_> {
189    ExpoMessage {
190        to: &message.token,
191        title: &message.title,
192        body: &message.body,
193        sound: "default",
194        data: &message.data,
195        channel_id: message.channel_id.as_deref(),
196    }
197}
198
199fn rejection(details: Option<&ExpoErrorDetails>) -> Option<PushRejection> {
200    details
201        .and_then(|details| details.error.as_deref())
202        .map(PushRejection::from_code)
203}
204
205impl PushSender for ExpoPushSender {
206    fn send<'a>(&'a self, batch: Vec<PushMessage>) -> PushFuture<'a> {
207        Box::pin(async move {
208            let mut outcomes = Vec::with_capacity(batch.len());
209            for chunk in batch.chunks(self.options.batch_size()) {
210                outcomes.extend(self.send_chunk(chunk).await?);
211            }
212            Ok(outcomes)
213        })
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use std::time::Duration;
220
221    use baukit_http::RetryClass;
222
223    use super::*;
224
225    fn message(token: &str) -> PushMessage {
226        PushMessage::new(token, "Reminder", "Your session starts soon")
227            .with_data("session_id", "abc")
228    }
229
230    #[test]
231    fn a_message_serializes_into_expos_camel_case_shape() {
232        let payload = serde_json::to_value(expo_message(
233            &message("ExponentPushToken[x]").with_channel_id("reminders"),
234        ))
235        .expect("message should serialize");
236        assert_eq!(payload["to"], "ExponentPushToken[x]");
237        assert_eq!(payload["title"], "Reminder");
238        assert_eq!(payload["body"], "Your session starts soon");
239        assert_eq!(payload["sound"], "default");
240        assert_eq!(payload["channelId"], "reminders");
241        assert_eq!(payload["data"]["session_id"], "abc");
242    }
243
244    #[test]
245    fn a_message_without_a_channel_omits_the_field_entirely() {
246        let payload =
247            serde_json::to_value(expo_message(&message("token"))).expect("message serializes");
248        assert!(payload.get("channelId").is_none());
249    }
250
251    #[test]
252    fn ticket_error_details_map_onto_neutral_rejections() {
253        let parsed: ExpoTicket = serde_json::from_value(serde_json::json!({
254            "status": "error",
255            "details": {"error": "DeviceNotRegistered"}
256        }))
257        .expect("ticket should parse");
258        assert_eq!(parsed.status, "error");
259        assert_eq!(
260            rejection(parsed.details.as_ref()),
261            Some(PushRejection::DeviceNotRegistered)
262        );
263    }
264
265    #[test]
266    fn an_error_without_details_has_no_specific_rejection() {
267        let parsed: ExpoTicket =
268            serde_json::from_value(serde_json::json!({"status": "error"})).expect("ticket parses");
269        assert_eq!(rejection(parsed.details.as_ref()), None);
270    }
271
272    #[test]
273    fn receipts_parse_keyed_by_ticket_id() {
274        let parsed: ExpoReceiptResponse = serde_json::from_value(serde_json::json!({
275            "data": {
276                "ticket-1": {"status": "ok"},
277                "ticket-2": {"status": "error", "details": {"error": "MessageTooBig"}}
278            }
279        }))
280        .expect("receipts should parse");
281        let data = parsed.data.expect("receipts have data");
282        assert_eq!(data["ticket-1"].status, OK_STATUS);
283        assert_eq!(
284            rejection(data["ticket-2"].details.as_ref()),
285            Some(PushRejection::MessageTooBig)
286        );
287    }
288
289    #[test]
290    fn upstream_statuses_classify_into_transport_errors() {
291        let mut headers = HeaderMap::new();
292        headers.insert(
293            axum::http::header::RETRY_AFTER,
294            axum::http::HeaderValue::from_static("12"),
295        );
296        assert_eq!(
297            transport_error(StatusCode::TOO_MANY_REQUESTS, &headers).retry_after(),
298            Some(Duration::from_secs(12))
299        );
300        assert!(matches!(
301            transport_error(StatusCode::UNAUTHORIZED, &HeaderMap::new()),
302            PushError::Transport {
303                class: RetryClass::Revoked
304            }
305        ));
306        assert!(matches!(
307            transport_error(StatusCode::BAD_GATEWAY, &HeaderMap::new()),
308            PushError::Transport {
309                class: RetryClass::Unavailable
310            }
311        ));
312        assert!(!transport_error(StatusCode::BAD_REQUEST, &HeaderMap::new()).is_retryable());
313    }
314}