1use 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#[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 pub fn new() -> Result<Self, PushOptionsError> {
88 Self::with_options(PushOptions::default())
89 }
90
91 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 #[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 #[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 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 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 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}