Skip to main content

baukit_push/
config.rs

1//! Push configuration and the validated options the adapter consumes.
2
3use std::time::Duration;
4
5use baukit_config::{Secret, Validate, ValidationError, ValidationErrors};
6use serde::{Deserialize, Deserializer};
7use thiserror::Error;
8
9/// Expo's public push send endpoint.
10pub const DEFAULT_EXPO_ENDPOINT: &str = "https://exp.host/--/api/v2/push/send";
11
12/// Largest batch Expo accepts in one `/push/send` request.
13pub const MAX_BATCH_SIZE: usize = 100;
14
15/// Most ticket IDs Expo accepts in one `/push/getReceipts` request.
16pub 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/// Deserializable push configuration for a product's config section.
22///
23/// Push is opt-in, so this does not live in `baukit_config::BaukitConfig`.
24/// Products embed it in their own product config, which puts environment
25/// overrides on the usual nested path, for example `ORDERS__PUSH__BATCH_SIZE`.
26/// `request_timeout_ms` is read in milliseconds and exposed as a [`Duration`].
27#[derive(Clone, Debug, Deserialize)]
28#[serde(default)]
29pub struct PushConfig {
30    /// Full push send endpoint URL.
31    pub endpoint: String,
32    /// Provider access token. Debug and display formatting redact its value.
33    ///
34    /// Expo only requires this when the project enforces push security.
35    pub access_token: Secret<String>,
36    /// Number of notifications sent in one upstream request.
37    pub batch_size: usize,
38    /// Timeout applied independently to the ticket and receipt requests.
39    #[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/// Validated options an [`ExpoPushSender`](crate::ExpoPushSender) is built from.
96///
97/// Construct these with [`PushOptions::new`] in code, or with
98/// [`PushOptions::from_config`] from a loaded [`PushConfig`]. Both reject the
99/// same invalid values, so a bad endpoint or batch size fails at startup rather
100/// than on the first notification.
101#[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    /// Creates options for a full push send endpoint URL.
112    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    /// Converts a loaded configuration section into validated options.
125    ///
126    /// An empty `access_token` means the project does not enforce push
127    /// security, so no `Authorization` header is sent.
128    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    /// Sets the bearer token sent with every push request.
142    ///
143    /// An empty or whitespace-only token is rejected; omit the call instead.
144    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    /// Sets how many notifications are sent in one upstream request.
157    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    /// Sets the timeout applied to each upstream request.
166    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    /// Returns the full push send endpoint URL.
178    #[must_use]
179    pub fn endpoint(&self) -> &str {
180        &self.endpoint
181    }
182
183    /// Returns the receipt endpoint derived next to the send endpoint.
184    #[must_use]
185    pub fn receipts_endpoint(&self) -> &str {
186        &self.receipts_endpoint
187    }
188
189    /// Returns the access token, if one is configured.
190    #[must_use]
191    pub fn access_token(&self) -> Option<&str> {
192        self.access_token.as_deref()
193    }
194
195    /// Returns the number of notifications sent in one upstream request.
196    #[must_use]
197    pub const fn batch_size(&self) -> usize {
198        self.batch_size
199    }
200
201    /// Returns the per-request timeout.
202    #[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/// Invalid push adapter configuration.
231#[derive(Debug, Error)]
232pub enum PushOptionsError {
233    /// Shared configuration invariants were violated.
234    #[error(transparent)]
235    Configuration(#[from] ValidationErrors),
236    /// The endpoint is not an absolute HTTP URL ending in `/send`.
237    #[error("endpoint must be an absolute HTTP URL ending in /send, got {0:?}")]
238    InvalidEndpoint(String),
239    /// The access token was set to an empty value.
240    #[error("access token must not be empty; omit it instead")]
241    EmptyAccessToken,
242    /// The batch size is outside the provider's accepted range.
243    #[error("batch size must be between 1 and {MAX_BATCH_SIZE}, got {0}")]
244    InvalidBatchSize(usize),
245    /// The request timeout is zero.
246    #[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}