Skip to main content

rskit_messaging/
config.rs

1use std::time::Duration;
2
3use rskit_errors::{AppError, AppResult, ErrorCode};
4use serde::Deserialize;
5
6// ── Base broker configuration ────────────────────────────────────────────────
7
8/// Configuration shared by all message-broker adapters.
9///
10/// Concrete broker configs embed this struct so generic code can work through
11/// [`BrokerConfigExt`] without knowing adapter-specific fields.
12#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
13pub struct BrokerConfig {
14    /// Adapter name used for registry selection.
15    #[serde(default = "default_adapter")]
16    pub adapter: String,
17    /// Logical name for this configuration.
18    #[serde(default = "default_name")]
19    pub name: String,
20    /// Whether this configuration is enabled.
21    #[serde(default = "default_enabled")]
22    pub enabled: bool,
23    /// Number of retries for failed requests.
24    #[serde(default = "default_retries")]
25    pub retries: u32,
26    /// Backoff between retry attempts in milliseconds.
27    #[serde(default = "default_retry_backoff")]
28    pub retry_backoff: u64,
29    /// Request timeout in milliseconds (`None` = use broker default).
30    #[serde(default)]
31    pub request_timeout: Option<u64>,
32    /// Requested delivery semantics.
33    #[serde(default)]
34    pub delivery_guarantee: DeliveryGuarantee,
35    /// Offset/ack commit behavior.
36    #[serde(default)]
37    pub commit_strategy: CommitStrategy,
38    /// Dead-letter queue policy.
39    #[serde(default)]
40    pub dlq: DlqPolicy,
41    /// Maximum number of in-flight messages.
42    #[serde(default = "default_max_in_flight")]
43    pub max_in_flight: usize,
44    /// Broker-neutral consumer group name.
45    #[serde(default)]
46    pub consumer_group: Option<String>,
47    /// Broker-neutral topic declarations.
48    #[serde(default)]
49    pub topics: Vec<String>,
50    /// Broker-neutral subscription subjects/topics. Empty means the consumer chooses at runtime.
51    #[serde(default)]
52    pub subscriptions: Vec<String>,
53}
54
55impl BrokerConfig {
56    /// Create a broker config with the provided adapter name and shared defaults.
57    #[must_use]
58    pub fn new(adapter: impl Into<String>) -> Self {
59        Self {
60            adapter: adapter.into(),
61            ..Self::default()
62        }
63    }
64
65    /// Return the request timeout as a [`Duration`], if configured.
66    #[must_use]
67    pub fn request_timeout_duration(&self) -> Option<Duration> {
68        self.request_timeout.map(Duration::from_millis)
69    }
70
71    /// Return retry backoff as a [`Duration`].
72    #[must_use]
73    pub const fn retry_backoff_duration(&self) -> Duration {
74        Duration::from_millis(self.retry_backoff)
75    }
76
77    /// Validate shared broker-neutral configuration.
78    pub fn validate(&self) -> AppResult<()> {
79        validate_name("messaging adapter", &self.adapter)?;
80        validate_name("messaging config name", &self.name)?;
81
82        if self.max_in_flight == 0 {
83            return invalid("messaging max_in_flight must be greater than zero");
84        }
85
86        if let Some(timeout) = self.request_timeout
87            && timeout == 0
88        {
89            return invalid("messaging request_timeout must be greater than zero when set");
90        }
91
92        if self.retries > 0 && self.retry_backoff == 0 {
93            return invalid(
94                "messaging retry_backoff must be greater than zero when retries are enabled",
95            );
96        }
97
98        validate_optional_name("messaging consumer_group", self.consumer_group.as_deref())?;
99
100        for topic in &self.topics {
101            validate_topic_like("messaging topic", topic)?;
102        }
103        for subscription in &self.subscriptions {
104            validate_topic_like("messaging subscription", subscription)?;
105        }
106
107        self.dlq.validate()
108    }
109}
110
111impl Default for BrokerConfig {
112    fn default() -> Self {
113        Self {
114            adapter: default_adapter(),
115            name: default_name(),
116            enabled: default_enabled(),
117            retries: default_retries(),
118            retry_backoff: default_retry_backoff(),
119            request_timeout: None,
120            delivery_guarantee: DeliveryGuarantee::default(),
121            commit_strategy: CommitStrategy::default(),
122            dlq: DlqPolicy::default(),
123            max_in_flight: default_max_in_flight(),
124            consumer_group: None,
125            topics: Vec::new(),
126            subscriptions: Vec::new(),
127        }
128    }
129}
130
131/// Partial broker config used by adapter crates to apply only user-provided
132/// shared settings over adapter-specific defaults.
133#[doc(hidden)]
134#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
135pub struct BrokerConfigOverrides {
136    /// Adapter name used for registry selection.
137    pub adapter: Option<String>,
138    /// Logical name for this configuration.
139    pub name: Option<String>,
140    /// Whether this configuration is enabled.
141    pub enabled: Option<bool>,
142    /// Number of retries for failed requests.
143    pub retries: Option<u32>,
144    /// Backoff between retry attempts in milliseconds.
145    pub retry_backoff: Option<u64>,
146    /// Request timeout in milliseconds.
147    pub request_timeout: Option<Option<u64>>,
148    /// Requested delivery semantics.
149    pub delivery_guarantee: Option<DeliveryGuarantee>,
150    /// Offset/ack commit behavior.
151    pub commit_strategy: Option<CommitStrategy>,
152    /// Dead-letter queue policy.
153    pub dlq: Option<DlqPolicy>,
154    /// Maximum number of in-flight messages.
155    pub max_in_flight: Option<usize>,
156    /// Broker-neutral consumer group name.
157    pub consumer_group: Option<Option<String>>,
158    /// Broker-neutral topic declarations.
159    pub topics: Option<Vec<String>>,
160    /// Broker-neutral subscription subjects/topics.
161    pub subscriptions: Option<Vec<String>>,
162}
163
164impl BrokerConfigOverrides {
165    /// Apply explicitly provided fields to `base`.
166    pub fn apply_to(self, base: &mut BrokerConfig) {
167        if let Some(value) = self.adapter {
168            base.adapter = value;
169        }
170        if let Some(value) = self.name {
171            base.name = value;
172        }
173        if let Some(value) = self.enabled {
174            base.enabled = value;
175        }
176        if let Some(value) = self.retries {
177            base.retries = value;
178        }
179        if let Some(value) = self.retry_backoff {
180            base.retry_backoff = value;
181        }
182        if let Some(value) = self.request_timeout {
183            base.request_timeout = value;
184        }
185        if let Some(value) = self.delivery_guarantee {
186            base.delivery_guarantee = value;
187        }
188        if let Some(value) = self.commit_strategy {
189            base.commit_strategy = value;
190        }
191        if let Some(value) = self.dlq {
192            base.dlq = value;
193        }
194        if let Some(value) = self.max_in_flight {
195            base.max_in_flight = value;
196        }
197        if let Some(value) = self.consumer_group {
198            base.consumer_group = value;
199        }
200        if let Some(value) = self.topics {
201            base.topics = value;
202        }
203        if let Some(value) = self.subscriptions {
204            base.subscriptions = value;
205        }
206    }
207}
208
209/// Extension trait for broker-specific configurations.
210///
211/// Every adapter configuration struct should implement this so that generic
212/// infrastructure (retry policies, health checks, service discovery) can
213/// access the common [`BrokerConfig`] and perform validation without knowing
214/// the concrete broker type.
215pub trait BrokerConfigExt {
216    /// Access the shared broker configuration.
217    fn base(&self) -> &BrokerConfig;
218    /// Validate the complete configuration (base + adapter-specific fields).
219    fn validate(&self) -> AppResult<()>;
220}
221
222const fn default_retries() -> u32 {
223    3
224}
225
226const fn default_retry_backoff() -> u64 {
227    100
228}
229
230fn default_adapter() -> String {
231    "memory".to_string()
232}
233
234const fn default_max_in_flight() -> usize {
235    1
236}
237
238fn default_name() -> String {
239    "default".to_string()
240}
241
242/// Requested broker delivery semantics.
243#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
244#[non_exhaustive]
245#[serde(rename_all = "snake_case")]
246pub enum DeliveryGuarantee {
247    /// Message loss is allowed but redelivery is avoided.
248    AtMostOnce,
249    /// Default: messages may redeliver after failure.
250    #[default]
251    AtLeastOnce,
252    /// Broker-supported exactly-once semantics.
253    ExactlyOnce,
254}
255
256/// Offset/ack commit behavior.
257#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
258#[non_exhaustive]
259#[serde(rename_all = "snake_case")]
260pub enum CommitStrategy {
261    /// Delegate commit behavior to the broker client.
262    Auto,
263    /// Default: commit after handler success.
264    #[default]
265    PostHandlerSuccess,
266    /// Application code commits manually.
267    Manual,
268}
269
270/// Dead-letter queue policy.
271#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
272pub struct DlqPolicy {
273    /// Whether DLQ routing is enabled.
274    #[serde(default = "default_enabled")]
275    pub enabled: bool,
276    /// Suffix appended to source topic when no explicit DLQ topic is configured.
277    #[serde(default = "default_dlq_suffix")]
278    pub suffix: String,
279}
280
281impl Default for DlqPolicy {
282    fn default() -> Self {
283        Self {
284            enabled: true,
285            suffix: default_dlq_suffix(),
286        }
287    }
288}
289
290impl DlqPolicy {
291    /// Validate DLQ policy fields.
292    pub fn validate(&self) -> AppResult<()> {
293        if !self.enabled {
294            return Ok(());
295        }
296
297        if self.suffix.is_empty() {
298            return invalid("messaging dlq suffix is required when DLQ is enabled");
299        }
300        if self.suffix.len() > 64 {
301            return invalid("messaging dlq suffix must be at most 64 bytes");
302        }
303        if self.suffix.chars().any(char::is_whitespace) {
304            return invalid("messaging dlq suffix must not contain whitespace");
305        }
306        if self.suffix.contains('/') || self.suffix.contains('\\') {
307            return invalid("messaging dlq suffix must not contain path separators");
308        }
309
310        Ok(())
311    }
312}
313
314const fn default_enabled() -> bool {
315    true
316}
317
318fn default_dlq_suffix() -> String {
319    ".dlq".to_string()
320}
321
322fn validate_optional_name(field: &str, value: Option<&str>) -> AppResult<()> {
323    if let Some(value) = value {
324        validate_topic_like(field, value)?;
325    }
326    Ok(())
327}
328
329fn validate_topic_like(field: &str, value: &str) -> AppResult<()> {
330    if value.trim().is_empty() {
331        return invalid(format!("{field} is required"));
332    }
333    if value
334        .chars()
335        .any(|ch| ch.is_control() || ch.is_whitespace())
336    {
337        return invalid(format!(
338            "{field} must not contain whitespace or control characters"
339        ));
340    }
341    Ok(())
342}
343
344fn validate_name(field: &str, value: &str) -> AppResult<()> {
345    if value.trim().is_empty() {
346        return invalid(format!("{field} is required"));
347    }
348    if value.len() > 128 {
349        return invalid(format!("{field} must be at most 128 bytes"));
350    }
351    if !value
352        .chars()
353        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
354    {
355        return invalid(format!(
356            "{field} must contain only letters, digits, ., _, or -"
357        ));
358    }
359    Ok(())
360}
361
362fn invalid(message: impl Into<String>) -> AppResult<()> {
363    Err(AppError::new(ErrorCode::InvalidInput, message.into()))
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn broker_config_defaults_are_broker_neutral() {
372        let config = BrokerConfig::default();
373
374        assert_eq!(config.adapter, "memory");
375        assert_eq!(config.name, "default");
376        assert!(config.enabled);
377        assert_eq!(config.retries, 3);
378        assert_eq!(config.retry_backoff_duration(), Duration::from_millis(100));
379        assert!(config.request_timeout_duration().is_none());
380        assert_eq!(config.delivery_guarantee, DeliveryGuarantee::AtLeastOnce);
381        assert_eq!(config.commit_strategy, CommitStrategy::PostHandlerSuccess);
382        assert_eq!(config.max_in_flight, 1);
383        assert!(config.consumer_group.is_none());
384        assert!(config.topics.is_empty());
385        assert!(config.subscriptions.is_empty());
386        assert!(config.validate().is_ok());
387    }
388
389    #[test]
390    fn broker_config_validation_rejects_invalid_shared_values() {
391        let config = BrokerConfig {
392            max_in_flight: 0,
393            ..Default::default()
394        };
395        assert!(config.validate().is_err());
396
397        let config = BrokerConfig {
398            request_timeout: Some(0),
399            ..Default::default()
400        };
401        assert!(config.validate().is_err());
402
403        let config = BrokerConfig {
404            retry_backoff: 0,
405            ..Default::default()
406        };
407        assert!(config.validate().is_err());
408
409        let mut config = BrokerConfig::default();
410        config.dlq.suffix = "bad suffix".to_string();
411        assert!(config.validate().is_err());
412    }
413}