faucet_sink_pubsub/
config.rs1use faucet_common_pubsub::PubsubConnection;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
9#[serde(rename_all = "snake_case")]
10pub enum ValueFormat {
11 #[default]
13 Json,
14 String,
16 Bytes,
18}
19
20#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
25#[serde(tag = "type", rename_all = "snake_case")]
26pub enum OrderingKey {
27 #[default]
29 None,
30 Field {
33 name: String,
35 },
36 Jsonpath {
38 path: String,
40 },
41}
42
43impl OrderingKey {
44 pub fn enables_ordering(&self) -> bool {
47 !matches!(self, OrderingKey::None)
48 }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
53pub struct PubsubSinkConfig {
54 pub topic: String,
57
58 #[serde(flatten)]
60 pub connection: PubsubConnection,
61
62 #[serde(default)]
64 pub value_format: ValueFormat,
65
66 #[serde(default)]
68 pub ordering_key: OrderingKey,
69
70 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub attributes_field: Option<String>,
75
76 #[serde(default = "default_batch_size")]
78 pub batch_size: usize,
79
80 #[serde(default = "default_concurrency")]
82 pub concurrency: usize,
83}
84
85pub 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 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 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}