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