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 PushDeliveryStatus, PushError, PushFuture, PushMessage, PushOptions, PushOptionsError,
11 PushOutcome, PushRejection, PushSender,
12};
13
14const OK_STATUS: &str = "ok";
15
16#[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 pub fn new() -> Result<Self, PushOptionsError> {
74 Self::with_options(PushOptions::default())
75 }
76
77 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 #[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 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 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}