1use 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#[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 pub fn new() -> Result<Self, PushOptionsError> {
77 Self::with_options(PushOptions::default())
78 }
79
80 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 #[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 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 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}