Skip to main content

faucet_sink_pubsub/
config.rs

1//! Configuration for the Pub/Sub sink. No I/O here.
2
3use faucet_common_pubsub::PubsubConnection;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7/// How each record is encoded into the Pub/Sub message `data` blob.
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
9#[serde(rename_all = "snake_case")]
10pub enum ValueFormat {
11    /// Serialize the whole record as JSON bytes.
12    #[default]
13    Json,
14    /// The record must be a JSON string → raw UTF-8 bytes.
15    String,
16    /// The record must be a base64 JSON string → decoded bytes.
17    Bytes,
18}
19
20/// How each message's ordering key is derived. When any non-empty ordering key
21/// is produced, message ordering is enabled on the publisher.
22///
23/// Serializes as `{ type: <strategy>, … }` (snake_case discriminators).
24#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
25#[serde(tag = "type", rename_all = "snake_case")]
26pub enum OrderingKey {
27    /// No ordering key — messages may be delivered in any order (default).
28    #[default]
29    None,
30    /// A top-level field's value, stringified. A record missing the field (or
31    /// with a null / container value) fails per-record (DLQ-routable).
32    Field {
33        /// Top-level field name.
34        name: String,
35    },
36    /// A dot-path into the record (`a.b.c`, object keys only), stringified.
37    Jsonpath {
38        /// Dot path, e.g. `order.id`.
39        path: String,
40    },
41}
42
43impl OrderingKey {
44    /// Whether this strategy ever produces an ordering key (so the publisher
45    /// must enable message ordering).
46    pub fn enables_ordering(&self) -> bool {
47        !matches!(self, OrderingKey::None)
48    }
49}
50
51/// Configuration for [`PubsubSink`](crate::PubsubSink).
52#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
53pub struct PubsubSinkConfig {
54    /// Topic id (short name, not the fully-qualified path). The client forms
55    /// `projects/<project>/topics/<id>` from the connection's project.
56    pub topic: String,
57
58    /// Project / endpoint / emulator / credentials (flattened).
59    #[serde(flatten)]
60    pub connection: PubsubConnection,
61
62    /// Record payload encoding. Default: `json`.
63    #[serde(default)]
64    pub value_format: ValueFormat,
65
66    /// Ordering-key derivation. Default: `none`.
67    #[serde(default)]
68    pub ordering_key: OrderingKey,
69
70    /// Optional record field holding a JSON object of message attributes.
71    /// Values are stringified; the field is stripped from the payload before
72    /// encoding. Absent field → no attributes (not an error).
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub attributes_field: Option<String>,
75
76    /// Records per publish batch (1–1000). Default 100.
77    #[serde(default = "default_batch_size")]
78    pub batch_size: usize,
79
80    /// Bounded concurrent in-flight publishes. Default 4.
81    #[serde(default = "default_concurrency")]
82    pub concurrency: usize,
83}
84
85/// Pub/Sub caps a single `Publish` request at 1000 messages.
86pub const MAX_BATCH: usize = 1000;
87
88fn default_batch_size() -> usize {
89    100
90}
91fn default_concurrency() -> usize {
92    4
93}
94
95impl PubsubSinkConfig {
96    /// Minimal config with defaults for everything but the topic id.
97    pub fn new(topic: impl Into<String>) -> Self {
98        Self {
99            topic: topic.into(),
100            connection: PubsubConnection::default(),
101            value_format: ValueFormat::default(),
102            ordering_key: OrderingKey::default(),
103            attributes_field: None,
104            batch_size: default_batch_size(),
105            concurrency: default_concurrency(),
106        }
107    }
108
109    /// Fail-fast validation, called from `PubsubSink::new`.
110    pub fn validate(&self) -> Result<(), faucet_core::FaucetError> {
111        use faucet_core::FaucetError;
112        if self.topic.trim().is_empty() {
113            return Err(FaucetError::Config(
114                "pubsub sink: topic must not be empty".into(),
115            ));
116        }
117        if self.batch_size == 0 || self.batch_size > MAX_BATCH {
118            return Err(FaucetError::Config(format!(
119                "pubsub sink: batch_size must be 1..={MAX_BATCH} (got {})",
120                self.batch_size
121            )));
122        }
123        if self.concurrency == 0 {
124            return Err(FaucetError::Config(
125                "pubsub sink: concurrency must be at least 1".into(),
126            ));
127        }
128        match &self.ordering_key {
129            OrderingKey::Field { name } if name.trim().is_empty() => {
130                return Err(FaucetError::Config(
131                    "pubsub sink: ordering_key.name must not be empty".into(),
132                ));
133            }
134            OrderingKey::Jsonpath { path } if path.trim().is_empty() => {
135                return Err(FaucetError::Config(
136                    "pubsub sink: ordering_key.path must not be empty".into(),
137                ));
138            }
139            _ => {}
140        }
141        if let Some(field) = &self.attributes_field
142            && field.trim().is_empty()
143        {
144            return Err(FaucetError::Config(
145                "pubsub sink: attributes_field must not be empty when set".into(),
146            ));
147        }
148        Ok(())
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn defaults_are_sensible() {
158        let c = PubsubSinkConfig::new("orders");
159        c.validate().unwrap();
160        assert_eq!(c.value_format, ValueFormat::Json);
161        assert_eq!(c.ordering_key, OrderingKey::None);
162        assert!(!c.ordering_key.enables_ordering());
163        assert_eq!(c.batch_size, 100);
164        assert_eq!(c.concurrency, 4);
165    }
166
167    #[test]
168    fn ordering_key_enables_flag() {
169        assert!(OrderingKey::Field { name: "k".into() }.enables_ordering());
170        assert!(OrderingKey::Jsonpath { path: "a.b".into() }.enables_ordering());
171        assert!(!OrderingKey::None.enables_ordering());
172    }
173
174    #[test]
175    fn validation_bounds() {
176        let mut c = PubsubSinkConfig::new("orders");
177        c.topic = "  ".into();
178        assert!(c.validate().is_err());
179
180        let mut c = PubsubSinkConfig::new("orders");
181        c.batch_size = 0;
182        assert!(c.validate().is_err());
183        c.batch_size = MAX_BATCH + 1;
184        assert!(c.validate().is_err());
185
186        let mut c = PubsubSinkConfig::new("orders");
187        c.concurrency = 0;
188        assert!(c.validate().is_err());
189
190        let mut c = PubsubSinkConfig::new("orders");
191        c.ordering_key = OrderingKey::Field { name: " ".into() };
192        assert!(c.validate().is_err());
193        c.ordering_key = OrderingKey::Jsonpath { path: "".into() };
194        assert!(c.validate().is_err());
195
196        let mut c = PubsubSinkConfig::new("orders");
197        c.attributes_field = Some("".into());
198        assert!(c.validate().is_err());
199    }
200
201    #[test]
202    fn full_config_parses_from_yaml() {
203        let yaml = r#"
204topic: orders
205project_id: my-proj
206credentials: { type: application_default }
207value_format: json
208ordering_key: { type: field, name: customer_id }
209attributes_field: __attributes
210batch_size: 250
211concurrency: 8
212"#;
213        let c: PubsubSinkConfig = serde_yaml::from_str(yaml).unwrap();
214        c.validate().unwrap();
215        assert_eq!(
216            c.ordering_key,
217            OrderingKey::Field {
218                name: "customer_id".into()
219            }
220        );
221        assert_eq!(c.attributes_field.as_deref(), Some("__attributes"));
222        assert_eq!(c.batch_size, 250);
223        assert_eq!(c.concurrency, 8);
224    }
225}