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