1use std::time::Duration;
4
5use baukit_config::{Secret, Validate, ValidationError, ValidationErrors};
6use serde::{Deserialize, Deserializer};
7use thiserror::Error;
8
9pub const DEFAULT_EXPO_ENDPOINT: &str = "https://exp.host/--/api/v2/push/send";
11
12pub const MAX_BATCH_SIZE: usize = 100;
14
15pub const MAX_RECEIPT_BATCH_SIZE: usize = 1000;
17
18const DEFAULT_BATCH_SIZE: usize = 100;
19const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
20
21#[derive(Clone, Debug, Deserialize)]
28#[serde(default)]
29pub struct PushConfig {
30 pub endpoint: String,
32 pub access_token: Secret<String>,
36 pub batch_size: usize,
38 #[serde(
40 rename = "request_timeout_ms",
41 deserialize_with = "duration_from_millis"
42 )]
43 pub request_timeout: Duration,
44}
45
46fn duration_from_millis<'de, D>(deserializer: D) -> Result<Duration, D::Error>
47where
48 D: Deserializer<'de>,
49{
50 u64::deserialize(deserializer).map(Duration::from_millis)
51}
52
53impl Default for PushConfig {
54 fn default() -> Self {
55 Self {
56 endpoint: DEFAULT_EXPO_ENDPOINT.to_owned(),
57 access_token: Secret::new(String::new()),
58 batch_size: DEFAULT_BATCH_SIZE,
59 request_timeout: DEFAULT_REQUEST_TIMEOUT,
60 }
61 }
62}
63
64impl Validate for PushConfig {
65 fn validate(&self) -> Result<(), ValidationErrors> {
66 let mut errors = Vec::new();
67 if self.endpoint.trim().is_empty() {
68 errors.push(ValidationError::new("endpoint", "must not be empty"));
69 } else if validated_endpoints(&self.endpoint).is_err() {
70 errors.push(ValidationError::new(
71 "endpoint",
72 "must be an absolute HTTP URL ending in /send",
73 ));
74 }
75 if self.batch_size == 0 || self.batch_size > MAX_BATCH_SIZE {
76 errors.push(ValidationError::new(
77 "batch_size",
78 format!("must be between 1 and {MAX_BATCH_SIZE}"),
79 ));
80 }
81 if self.request_timeout.is_zero() {
82 errors.push(ValidationError::new(
83 "request_timeout_ms",
84 "must be greater than zero",
85 ));
86 }
87 if errors.is_empty() {
88 Ok(())
89 } else {
90 Err(ValidationErrors::new(errors))
91 }
92 }
93}
94
95#[derive(Clone)]
102pub struct PushOptions {
103 endpoint: String,
104 receipts_endpoint: String,
105 access_token: Option<String>,
106 batch_size: usize,
107 request_timeout: Duration,
108}
109
110impl PushOptions {
111 pub fn new(endpoint: impl Into<String>) -> Result<Self, PushOptionsError> {
113 let endpoint = endpoint.into();
114 let (endpoint, receipts_endpoint) = validated_endpoints(&endpoint)?;
115 Ok(Self {
116 endpoint,
117 receipts_endpoint,
118 access_token: None,
119 batch_size: DEFAULT_BATCH_SIZE,
120 request_timeout: DEFAULT_REQUEST_TIMEOUT,
121 })
122 }
123
124 pub fn from_config(config: &PushConfig) -> Result<Self, PushOptionsError> {
129 config.validate()?;
130 let access_token = config.access_token.expose().trim();
131 let (endpoint, receipts_endpoint) = validated_endpoints(&config.endpoint)?;
132 Ok(Self {
133 endpoint,
134 receipts_endpoint,
135 access_token: (!access_token.is_empty()).then(|| access_token.to_owned()),
136 batch_size: config.batch_size,
137 request_timeout: config.request_timeout,
138 })
139 }
140
141 pub fn with_access_token(
145 mut self,
146 access_token: impl Into<String>,
147 ) -> Result<Self, PushOptionsError> {
148 let access_token = access_token.into();
149 if access_token.trim().is_empty() {
150 return Err(PushOptionsError::EmptyAccessToken);
151 }
152 self.access_token = Some(access_token);
153 Ok(self)
154 }
155
156 pub fn with_batch_size(mut self, batch_size: usize) -> Result<Self, PushOptionsError> {
158 if batch_size == 0 || batch_size > MAX_BATCH_SIZE {
159 return Err(PushOptionsError::InvalidBatchSize(batch_size));
160 }
161 self.batch_size = batch_size;
162 Ok(self)
163 }
164
165 pub fn with_request_timeout(
167 mut self,
168 request_timeout: Duration,
169 ) -> Result<Self, PushOptionsError> {
170 if request_timeout.is_zero() {
171 return Err(PushOptionsError::ZeroRequestTimeout);
172 }
173 self.request_timeout = request_timeout;
174 Ok(self)
175 }
176
177 #[must_use]
179 pub fn endpoint(&self) -> &str {
180 &self.endpoint
181 }
182
183 #[must_use]
185 pub fn receipts_endpoint(&self) -> &str {
186 &self.receipts_endpoint
187 }
188
189 #[must_use]
191 pub fn access_token(&self) -> Option<&str> {
192 self.access_token.as_deref()
193 }
194
195 #[must_use]
197 pub const fn batch_size(&self) -> usize {
198 self.batch_size
199 }
200
201 #[must_use]
203 pub const fn request_timeout(&self) -> Duration {
204 self.request_timeout
205 }
206}
207
208impl Default for PushOptions {
209 fn default() -> Self {
210 Self::from_config(&PushConfig::default()).expect("the standard push defaults are valid")
211 }
212}
213
214impl std::fmt::Debug for PushOptions {
215 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216 formatter
217 .debug_struct("PushOptions")
218 .field("endpoint", &self.endpoint)
219 .field("receipts_endpoint", &self.receipts_endpoint)
220 .field(
221 "access_token",
222 &self.access_token.as_ref().map(|_| "[redacted]"),
223 )
224 .field("batch_size", &self.batch_size)
225 .field("request_timeout", &self.request_timeout)
226 .finish()
227 }
228}
229
230#[derive(Debug, Error)]
232pub enum PushOptionsError {
233 #[error(transparent)]
235 Configuration(#[from] ValidationErrors),
236 #[error("endpoint must be an absolute HTTP URL ending in /send, got {0:?}")]
238 InvalidEndpoint(String),
239 #[error("access token must not be empty; omit it instead")]
241 EmptyAccessToken,
242 #[error("batch size must be between 1 and {MAX_BATCH_SIZE}, got {0}")]
244 InvalidBatchSize(usize),
245 #[error("request timeout must be greater than zero")]
247 ZeroRequestTimeout,
248}
249
250fn validated_endpoints(endpoint: &str) -> Result<(String, String), PushOptionsError> {
251 let endpoint = endpoint.trim();
252 let parsed = reqwest::Url::parse(endpoint)
253 .map_err(|_| PushOptionsError::InvalidEndpoint(endpoint.to_owned()))?;
254 if !matches!(parsed.scheme(), "http" | "https")
255 || parsed.host_str().is_none()
256 || !parsed.path().ends_with("/send")
257 {
258 return Err(PushOptionsError::InvalidEndpoint(endpoint.to_owned()));
259 }
260 let receipts = parsed
261 .join("getReceipts")
262 .map_err(|_| PushOptionsError::InvalidEndpoint(endpoint.to_owned()))?;
263 Ok((parsed.to_string(), receipts.to_string()))
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn defaults_point_at_the_public_expo_api() {
272 let options = PushOptions::default();
273 assert_eq!(options.endpoint(), DEFAULT_EXPO_ENDPOINT);
274 assert_eq!(
275 options.receipts_endpoint(),
276 "https://exp.host/--/api/v2/push/getReceipts"
277 );
278 assert_eq!(options.access_token(), None);
279 assert_eq!(options.batch_size(), MAX_BATCH_SIZE);
280 assert_eq!(options.request_timeout(), DEFAULT_REQUEST_TIMEOUT);
281 }
282
283 #[test]
284 fn the_receipts_endpoint_is_derived_next_to_send() -> Result<(), PushOptionsError> {
285 let options = PushOptions::new("https://push.example.com/api/push/send")?;
286 assert_eq!(options.endpoint(), "https://push.example.com/api/push/send");
287 assert_eq!(
288 options.receipts_endpoint(),
289 "https://push.example.com/api/push/getReceipts"
290 );
291 Ok(())
292 }
293
294 #[test]
295 fn invalid_options_are_rejected_at_construction() {
296 assert!(matches!(
297 PushOptions::new("not a URL"),
298 Err(PushOptionsError::InvalidEndpoint(_))
299 ));
300 assert!(matches!(
301 PushOptions::new(""),
302 Err(PushOptionsError::InvalidEndpoint(_))
303 ));
304 assert!(matches!(
305 PushOptions::new("https://push.example.com/api/v2"),
306 Err(PushOptionsError::InvalidEndpoint(_))
307 ));
308 let options = PushOptions::default();
309 assert!(matches!(
310 options.clone().with_batch_size(0),
311 Err(PushOptionsError::InvalidBatchSize(0))
312 ));
313 assert!(matches!(
314 options.clone().with_batch_size(MAX_BATCH_SIZE + 1),
315 Err(PushOptionsError::InvalidBatchSize(101))
316 ));
317 assert!(matches!(
318 options.clone().with_request_timeout(Duration::ZERO),
319 Err(PushOptionsError::ZeroRequestTimeout)
320 ));
321 assert!(matches!(
322 options.with_access_token(" "),
323 Err(PushOptionsError::EmptyAccessToken)
324 ));
325 }
326
327 #[test]
328 fn every_invalid_config_field_is_reported_at_once() {
329 let config = PushConfig {
330 endpoint: String::new(),
331 batch_size: 0,
332 request_timeout: Duration::ZERO,
333 ..PushConfig::default()
334 };
335 let error = PushOptions::from_config(&config).expect_err("invalid config");
336 let rendered = error.to_string();
337 assert!(rendered.contains("endpoint"));
338 assert!(rendered.contains("batch_size"));
339 assert!(rendered.contains("request_timeout_ms"));
340 }
341
342 #[test]
343 fn a_malformed_endpoint_is_reported_separately_from_an_empty_one() {
344 let config = PushConfig {
345 endpoint: "not a URL".to_owned(),
346 ..PushConfig::default()
347 };
348 let rendered = PushOptions::from_config(&config)
349 .expect_err("invalid config")
350 .to_string();
351 assert!(rendered.contains("absolute HTTP URL ending in /send"));
352 }
353
354 #[test]
355 fn an_empty_configured_access_token_sends_no_authorization() -> Result<(), PushOptionsError> {
356 assert_eq!(
357 PushOptions::from_config(&PushConfig::default())?.access_token(),
358 None
359 );
360 let config = PushConfig {
361 access_token: Secret::new(" expo-token ".to_owned()),
362 ..PushConfig::default()
363 };
364 assert_eq!(
365 PushOptions::from_config(&config)?.access_token(),
366 Some("expo-token")
367 );
368 Ok(())
369 }
370
371 #[test]
372 fn the_access_token_never_appears_in_debug_output() -> Result<(), PushOptionsError> {
373 let options = PushOptions::default().with_access_token("super-secret")?;
374 let rendered = format!("{options:?}");
375 assert!(!rendered.contains("super-secret"));
376 assert!(rendered.contains("[redacted]"));
377 Ok(())
378 }
379
380 #[test]
381 fn the_config_section_deserializes_timeouts_in_milliseconds() {
382 let config: PushConfig =
383 serde_json::from_str(r#"{"batch_size":10,"request_timeout_ms":250}"#)
384 .expect("config should deserialize");
385 assert_eq!(config.batch_size, 10);
386 assert_eq!(config.request_timeout, Duration::from_millis(250));
387 assert_eq!(config.endpoint, DEFAULT_EXPO_ENDPOINT);
388 }
389}