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
15const DEFAULT_BATCH_SIZE: usize = 100;
16const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
17
18/// Deserializable push configuration for a product's config section.
19///
20/// Push is opt-in, so this does not live in `baukit_config::BaukitConfig`.
21/// Products embed it in their own product config, which puts environment
22/// overrides on the usual nested path, for example `ORDERS__PUSH__BATCH_SIZE`.
23/// `request_timeout_ms` is read in milliseconds and exposed as a [`Duration`].
24#[derive(Clone, Debug, Deserialize)]
25#[serde(default)]
26pub struct PushConfig {
27    /// Full push send endpoint URL.
28    pub endpoint: String,
29    /// Provider access token. Debug and display formatting redact its value.
30    ///
31    /// Expo only requires this when the project enforces push security.
32    pub access_token: Secret<String>,
33    /// Number of notifications sent in one upstream request.
34    pub batch_size: usize,
35    /// Timeout applied independently to the ticket and receipt requests.
36    #[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/// Validated options an [`ExpoPushSender`](crate::ExpoPushSender) is built from.
93///
94/// Construct these with [`PushOptions::new`] in code, or with
95/// [`PushOptions::from_config`] from a loaded [`PushConfig`]. Both reject the
96/// same invalid values, so a bad endpoint or batch size fails at startup rather
97/// than on the first notification.
98#[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    /// Creates options for a full push send endpoint URL.
109    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    /// Converts a loaded configuration section into validated options.
122    ///
123    /// An empty `access_token` means the project does not enforce push
124    /// security, so no `Authorization` header is sent.
125    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    /// Sets the bearer token sent with every push request.
139    ///
140    /// An empty or whitespace-only token is rejected; omit the call instead.
141    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    /// Sets how many notifications are sent in one upstream request.
154    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    /// Sets the timeout applied to each upstream request.
163    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    /// Returns the full push send endpoint URL.
175    #[must_use]
176    pub fn endpoint(&self) -> &str {
177        &self.endpoint
178    }
179
180    /// Returns the receipt endpoint derived next to the send endpoint.
181    #[must_use]
182    pub fn receipts_endpoint(&self) -> &str {
183        &self.receipts_endpoint
184    }
185
186    /// Returns the access token, if one is configured.
187    #[must_use]
188    pub fn access_token(&self) -> Option<&str> {
189        self.access_token.as_deref()
190    }
191
192    /// Returns the number of notifications sent in one upstream request.
193    #[must_use]
194    pub const fn batch_size(&self) -> usize {
195        self.batch_size
196    }
197
198    /// Returns the per-request timeout.
199    #[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/// Invalid push adapter configuration.
228#[derive(Debug, Error)]
229pub enum PushOptionsError {
230    /// Shared configuration invariants were violated.
231    #[error(transparent)]
232    Configuration(#[from] ValidationErrors),
233    /// The endpoint is not an absolute HTTP URL ending in `/send`.
234    #[error("endpoint must be an absolute HTTP URL ending in /send, got {0:?}")]
235    InvalidEndpoint(String),
236    /// The access token was set to an empty value.
237    #[error("access token must not be empty; omit it instead")]
238    EmptyAccessToken,
239    /// The batch size is outside the provider's accepted range.
240    #[error("batch size must be between 1 and {MAX_BATCH_SIZE}, got {0}")]
241    InvalidBatchSize(usize),
242    /// The request timeout is zero.
243    #[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}