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, HeaderValue, StatusCode, header};
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)]
29pub struct ExpoPushSender {
30    client: reqwest::Client,
31    guarded: Option<baukit_egress::GuardedClient>,
32    options: PushOptions,
33}
34
35#[derive(Serialize)]
36#[serde(rename_all = "camelCase")]
37struct ExpoMessage<'a> {
38    to: &'a str,
39    title: &'a str,
40    body: &'a str,
41    sound: &'static str,
42    data: &'a BTreeMap<String, String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    channel_id: Option<&'a str>,
45}
46
47#[derive(Deserialize)]
48struct ExpoTicketResponse {
49    data: Option<Vec<ExpoTicket>>,
50}
51
52#[derive(Deserialize)]
53struct ExpoTicket {
54    status: String,
55    id: Option<String>,
56    details: Option<ExpoErrorDetails>,
57}
58
59#[derive(Deserialize)]
60struct ExpoReceiptResponse {
61    data: Option<HashMap<String, ExpoReceipt>>,
62}
63
64#[derive(Deserialize)]
65struct ExpoReceipt {
66    status: String,
67    details: Option<ExpoErrorDetails>,
68}
69
70#[derive(Deserialize)]
71struct ExpoErrorDetails {
72    error: Option<String>,
73}
74
75impl std::fmt::Debug for ExpoPushSender {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct("ExpoPushSender")
78            .field("batch_size", &self.options.batch_size())
79            .field("request_timeout", &self.options.request_timeout())
80            .field("guarded", &self.guarded.is_some())
81            .finish_non_exhaustive()
82    }
83}
84
85impl ExpoPushSender {
86    /// Builds a sender against the public Expo API with default options.
87    pub fn new() -> Result<Self, PushOptionsError> {
88        Self::with_options(PushOptions::default())
89    }
90
91    /// Builds a sender from validated options.
92    pub fn with_options(options: PushOptions) -> Result<Self, PushOptionsError> {
93        let client = reqwest::Client::builder()
94            .timeout(options.request_timeout())
95            .build()
96            .map_err(|error| PushOptionsError::InvalidEndpoint(error.to_string()))?;
97        Ok(Self {
98            client,
99            options,
100            guarded: None,
101        })
102    }
103
104    /// Sends ticket requests and immediate or deferred receipt requests through `client`.
105    ///
106    /// The client's policy, timeouts and response limits apply. Configure its
107    /// request timeout explicitly if it should match [`PushOptions::request_timeout`].
108    #[must_use]
109    pub fn with_guarded_client(mut self, client: baukit_egress::GuardedClient) -> Self {
110        self.guarded = Some(client);
111        self
112    }
113
114    /// Returns the options this sender was built from.
115    #[must_use]
116    pub const fn options(&self) -> &PushOptions {
117        &self.options
118    }
119
120    async fn send_chunk(&self, messages: &[PushMessage]) -> Result<Vec<PushOutcome>, PushError> {
121        let payload = messages.iter().map(expo_message).collect::<Vec<_>>();
122        let response: ExpoTicketResponse = self.post(self.options.endpoint(), &payload).await?;
123        let tickets = response
124            .data
125            .ok_or_else(|| PushError::InvalidResponse("ticket response has no data".to_owned()))?;
126        if tickets.len() != messages.len() {
127            return Err(PushError::InvalidResponse(format!(
128                "expected {} tickets, got {}",
129                messages.len(),
130                tickets.len()
131            )));
132        }
133
134        let mut outcomes = Vec::with_capacity(messages.len());
135        let mut pending = Vec::new();
136        for (message, ticket) in messages.iter().zip(tickets) {
137            match rejection(ticket.details.as_ref()) {
138                Some(rejection) => outcomes.push(PushOutcome {
139                    token: message.token.clone(),
140                    status: PushDeliveryStatus::Rejected(rejection),
141                }),
142                None if ticket.status == OK_STATUS => {
143                    pending.push((ticket_id(ticket.id)?, message.token.clone()));
144                }
145                None => outcomes.push(PushOutcome {
146                    token: message.token.clone(),
147                    status: PushDeliveryStatus::Rejected(PushRejection::ProviderError),
148                }),
149            }
150        }
151        if pending.is_empty() {
152            return Ok(outcomes);
153        }
154
155        let ids = pending.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>();
156        // Accepted tickets must survive a failed immediate receipt request.
157        // The durable receipt job settles them without sending again.
158        let mut receipts = self.fetch_receipts(&ids).await.unwrap_or_else(|error| {
159            tracing::debug!(
160                target: "baukit_push",
161                retryable = error.is_retryable(),
162                "immediate receipt fetch failed; tickets stay accepted"
163            );
164            HashMap::new()
165        });
166        outcomes.extend(pending.into_iter().map(|(id, token)| {
167            let status = match receipts.remove(&id) {
168                Some(receipt) => receipt.into(),
169                // Expo has not settled this notification yet. Callers must
170                // not resend on it; a deferred receipt poll reports the final
171                // state.
172                None => PushDeliveryStatus::Accepted(id),
173            };
174            PushOutcome { token, status }
175        }));
176        Ok(outcomes)
177    }
178
179    async fn fetch_receipts(
180        &self,
181        ids: &[PushTicketId],
182    ) -> Result<HashMap<PushTicketId, PushReceipt>, PushError> {
183        let mut settled = HashMap::new();
184        for chunk in ids.chunks(MAX_RECEIPT_BATCH_SIZE) {
185            let request = chunk.iter().map(PushTicketId::as_str).collect::<Vec<_>>();
186            let response: ExpoReceiptResponse = self
187                .post(
188                    self.options.receipts_endpoint(),
189                    &serde_json::json!({ "ids": request }),
190                )
191                .await?;
192            let mut receipts = response.data.unwrap_or_default();
193            settled.extend(chunk.iter().filter_map(|id| {
194                receipts
195                    .remove(id.as_str())
196                    .map(|receipt| (id.clone(), push_receipt(&receipt)))
197            }));
198        }
199        Ok(settled)
200    }
201
202    async fn post<T: serde::de::DeserializeOwned>(
203        &self,
204        endpoint: &str,
205        payload: &impl Serialize,
206    ) -> Result<T, PushError> {
207        if let Some(client) = &self.guarded {
208            return self.post_guarded(client, endpoint, payload).await;
209        }
210        let mut request = self.client.post(endpoint).json(payload);
211        if let Some(token) = self.options.access_token() {
212            request = request.bearer_auth(token);
213        }
214        let response = request.send().await.map_err(|error| {
215            tracing::debug!(target: "baukit_push", %error, endpoint, "expo request failed");
216            PushError::Transport {
217                class: classify_transport_error(error.is_timeout()),
218            }
219        })?;
220        let status = response.status();
221        if !status.is_success() {
222            return Err(transport_error(status, response.headers()));
223        }
224        response.json::<T>().await.map_err(|error| {
225            PushError::InvalidResponse(format!("{endpoint} response did not parse: {error}"))
226        })
227    }
228
229    async fn post_guarded<T: serde::de::DeserializeOwned>(
230        &self,
231        client: &baukit_egress::GuardedClient,
232        endpoint: &str,
233        payload: &impl Serialize,
234    ) -> Result<T, PushError> {
235        let url = endpoint
236            .parse()
237            .map_err(|_| PushError::InvalidResponse("invalid endpoint".into()))?;
238        let mut headers = HeaderMap::new();
239        headers.insert(
240            header::CONTENT_TYPE,
241            HeaderValue::from_static("application/json"),
242        );
243        if let Some(token) = self.options.access_token() {
244            let mut authorization: HeaderValue = format!("Bearer {token}")
245                .parse()
246                .map_err(|_| PushError::InvalidResponse("invalid credential".into()))?;
247            authorization.set_sensitive(true);
248            headers.insert(header::AUTHORIZATION, authorization);
249        }
250        let body = serde_json::to_vec(payload)
251            .map_err(|_| PushError::InvalidResponse("invalid request".into()))?;
252        let response = client
253            .execute(
254                baukit_egress::EgressRequest::post(url)
255                    .with_headers(headers)
256                    .with_body(body),
257            )
258            .await
259            .map_err(|error| PushError::Transport {
260                class: error.retry_class(),
261            })?;
262        serde_json::from_slice(response.body())
263            .map_err(|_| PushError::InvalidResponse("invalid response".into()))
264    }
265}
266
267fn transport_error(status: StatusCode, headers: &HeaderMap) -> PushError {
268    // Expo rejects a whole request for a bad token or a rate limit, so a 4xx
269    // that is not one of those still classifies as permanent and the caller
270    // learns not to retry it.
271    let class = classify_http_status(status, headers, &[]);
272    PushError::Transport { class }
273}
274
275fn ticket_id(id: Option<String>) -> Result<PushTicketId, PushError> {
276    id.ok_or_else(|| PushError::InvalidResponse("accepted ticket has no id".to_owned()))
277        .and_then(|id| {
278            PushTicketId::new(id).map_err(|error| PushError::InvalidResponse(error.to_string()))
279        })
280}
281
282fn push_receipt(receipt: &ExpoReceipt) -> PushReceipt {
283    match rejection(receipt.details.as_ref()) {
284        Some(rejection) => PushReceipt::Rejected(rejection),
285        None if receipt.status == OK_STATUS => PushReceipt::Delivered,
286        None => PushReceipt::Rejected(PushRejection::ProviderError),
287    }
288}
289
290fn expo_message(message: &PushMessage) -> ExpoMessage<'_> {
291    ExpoMessage {
292        to: message.token.expose(),
293        title: &message.title,
294        body: &message.body,
295        sound: "default",
296        data: &message.data,
297        channel_id: message.channel_id.as_deref(),
298    }
299}
300
301fn rejection(details: Option<&ExpoErrorDetails>) -> Option<PushRejection> {
302    details
303        .and_then(|details| details.error.as_deref())
304        .map(PushRejection::from_code)
305}
306
307impl PushSender for ExpoPushSender {
308    fn send<'a>(&'a self, batch: Vec<PushMessage>) -> PushFuture<'a> {
309        Box::pin(async move {
310            let mut outcomes = Vec::with_capacity(batch.len());
311            for chunk in batch.chunks(self.options.batch_size()) {
312                outcomes.extend(self.send_chunk(chunk).await?);
313            }
314            Ok(outcomes)
315        })
316    }
317}
318
319impl PushReceiptSource for ExpoPushSender {
320    fn receipts<'a>(&'a self, tickets: Vec<PushTicketId>) -> PushReceiptFuture<'a> {
321        Box::pin(async move { self.fetch_receipts(&tickets).await })
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use std::time::Duration;
328
329    use baukit_http::RetryClass;
330
331    use super::*;
332
333    fn message(token: &str) -> PushMessage {
334        let token = crate::DeviceToken::new(token).expect("valid test token");
335        PushMessage::new(token, "Reminder", "Your session starts soon")
336            .with_data("session_id", "abc")
337    }
338
339    #[test]
340    fn a_message_serializes_into_expos_camel_case_shape() {
341        let payload = serde_json::to_value(expo_message(
342            &message("ExponentPushToken[x]").with_channel_id("reminders"),
343        ))
344        .expect("message should serialize");
345        assert_eq!(payload["to"], "ExponentPushToken[x]");
346        assert_eq!(payload["title"], "Reminder");
347        assert_eq!(payload["body"], "Your session starts soon");
348        assert_eq!(payload["sound"], "default");
349        assert_eq!(payload["channelId"], "reminders");
350        assert_eq!(payload["data"]["session_id"], "abc");
351    }
352
353    #[test]
354    fn a_message_without_a_channel_omits_the_field_entirely() {
355        let payload =
356            serde_json::to_value(expo_message(&message("token"))).expect("message serializes");
357        assert!(payload.get("channelId").is_none());
358    }
359
360    #[test]
361    fn ticket_error_details_map_onto_neutral_rejections() {
362        let parsed: ExpoTicket = serde_json::from_value(serde_json::json!({
363            "status": "error",
364            "details": {"error": "DeviceNotRegistered"}
365        }))
366        .expect("ticket should parse");
367        assert_eq!(parsed.status, "error");
368        assert_eq!(
369            rejection(parsed.details.as_ref()),
370            Some(PushRejection::DeviceNotRegistered)
371        );
372    }
373
374    #[test]
375    fn an_error_without_details_has_no_specific_rejection() {
376        let parsed: ExpoTicket =
377            serde_json::from_value(serde_json::json!({"status": "error"})).expect("ticket parses");
378        assert_eq!(rejection(parsed.details.as_ref()), None);
379    }
380
381    #[test]
382    fn receipts_parse_keyed_by_ticket_id() {
383        let parsed: ExpoReceiptResponse = serde_json::from_value(serde_json::json!({
384            "data": {
385                "ticket-1": {"status": "ok"},
386                "ticket-2": {"status": "error", "details": {"error": "MessageTooBig"}}
387            }
388        }))
389        .expect("receipts should parse");
390        let data = parsed.data.expect("receipts have data");
391        assert_eq!(data["ticket-1"].status, OK_STATUS);
392        assert_eq!(
393            rejection(data["ticket-2"].details.as_ref()),
394            Some(PushRejection::MessageTooBig)
395        );
396    }
397
398    #[test]
399    fn upstream_statuses_classify_into_transport_errors() {
400        let mut headers = HeaderMap::new();
401        headers.insert(
402            axum::http::header::RETRY_AFTER,
403            axum::http::HeaderValue::from_static("12"),
404        );
405        assert_eq!(
406            transport_error(StatusCode::TOO_MANY_REQUESTS, &headers).retry_after(),
407            Some(Duration::from_secs(12))
408        );
409        assert!(matches!(
410            transport_error(StatusCode::UNAUTHORIZED, &HeaderMap::new()),
411            PushError::Transport {
412                class: RetryClass::Revoked
413            }
414        ));
415        assert!(matches!(
416            transport_error(StatusCode::BAD_GATEWAY, &HeaderMap::new()),
417            PushError::Transport {
418                class: RetryClass::Unavailable
419            }
420        ));
421        assert!(!transport_error(StatusCode::BAD_REQUEST, &HeaderMap::new()).is_retryable());
422    }
423}
424
425#[cfg(test)]
426mod guarded_transport_tests {
427    use super::*;
428    use axum::{Json, Router, routing::post};
429    use serde_json::{Value, json};
430    use std::sync::{
431        Arc,
432        atomic::{AtomicBool, AtomicUsize, Ordering},
433    };
434
435    #[tokio::test]
436    async fn guarded_batches_keep_accepted_tickets_when_receipts_fail()
437    -> Result<(), Box<dyn std::error::Error>> {
438        let sends = Arc::new(AtomicUsize::new(0));
439        let receipt_requests = Arc::new(AtomicUsize::new(0));
440        let ready = Arc::new(AtomicBool::new(false));
441        let app = Router::new()
442            .route(
443                "/push/send",
444                post({
445                    let sends = sends.clone();
446                    move |Json(messages): Json<Vec<Value>>| {
447                        let sends = sends.clone();
448                        async move {
449                            assert_eq!(messages.len(), 1, "configured batch size");
450                            let id = sends.fetch_add(1, Ordering::SeqCst);
451                            Json(json!({"data":[{"status":"ok","id":format!("guarded-{id}")}]}))
452                        }
453                    }
454                }),
455            )
456            .route(
457                "/push/getReceipts",
458                post({
459                    let ready = ready.clone();
460                    let receipt_requests = receipt_requests.clone();
461                    move |Json(request): Json<Value>| {
462                        let ready = ready.clone();
463                        let receipt_requests = receipt_requests.clone();
464                        async move {
465                            receipt_requests.fetch_add(1, Ordering::SeqCst);
466                            if !ready.load(Ordering::SeqCst) {
467                                return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({})));
468                            }
469                            let data = request["ids"]
470                                .as_array()
471                                .expect("ids")
472                                .iter()
473                                .map(|id| (
474                                    id.as_str().expect("ticket ID").to_owned(),
475                                    json!({"status":"error","details":{"error":"DeviceNotRegistered"}}),
476                                ))
477                                .collect::<serde_json::Map<_,_>>();
478                            (StatusCode::OK, Json(json!({"data":data})))
479                        }
480                    }
481                }),
482            );
483        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
484        let endpoint = format!("http://{}/push/send", listener.local_addr()?);
485        let task = tokio::spawn(async move {
486            axum::serve(listener, app).await.expect("Expo fixture");
487        });
488        let options = PushOptions::new(&endpoint)?.with_batch_size(1)?;
489        let sender = ExpoPushSender::with_options(options.clone())?.with_guarded_client(
490            baukit_egress::GuardedClient::new(
491                baukit_egress::EgressOptions::default()
492                    .with_policy(baukit_egress::AddressPolicy::AllowLoopback),
493            )?,
494        );
495        let messages = ["first", "second"]
496            .into_iter()
497            .map(|value| {
498                PushMessage::new(
499                    crate::DeviceToken::new(value).expect("token"),
500                    "Reminder",
501                    "Ready",
502                )
503            })
504            .collect();
505        let outcomes = sender.send(messages).await?;
506        assert_eq!(sends.load(Ordering::SeqCst), 2);
507        let tickets = outcomes
508            .iter()
509            .map(|outcome| match &outcome.status {
510                PushDeliveryStatus::Accepted(id) => id.clone(),
511                other => panic!("accepted ticket lost after receipt failure: {other:?}"),
512            })
513            .collect::<Vec<_>>();
514        ready.store(true, Ordering::SeqCst);
515        let receipts = sender.receipts(tickets.clone()).await?;
516        assert_eq!(receipts.len(), 2);
517        for id in &tickets {
518            assert_eq!(
519                receipts[id],
520                PushReceipt::Rejected(PushRejection::DeviceNotRegistered)
521            );
522        }
523        let blocked = ExpoPushSender::with_options(options)?.with_guarded_client(
524            baukit_egress::GuardedClient::new(baukit_egress::EgressOptions::default())?,
525        );
526        assert!(
527            blocked
528                .send(vec![PushMessage::new(
529                    crate::DeviceToken::new("blocked")?,
530                    "Reminder",
531                    "Ready"
532                )])
533                .await
534                .is_err()
535        );
536        assert!(blocked.receipts(tickets).await.is_err());
537        assert_eq!(sends.load(Ordering::SeqCst), 2);
538        assert_eq!(receipt_requests.load(Ordering::SeqCst), 3);
539        task.abort();
540        Ok(())
541    }
542}