faucet_source_mongodb_cdc/
config.rs1use faucet_core::{DEFAULT_BATCH_SIZE, FaucetError};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::fmt;
8use std::time::Duration;
9
10fn default_idle_timeout() -> Duration {
11 Duration::from_secs(30)
12}
13fn default_max_await_time_ms() -> u64 {
14 1000
15}
16fn default_batch_size() -> usize {
17 DEFAULT_BATCH_SIZE
18}
19
20#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
22#[serde(tag = "type", rename_all = "snake_case")]
23pub enum Scope {
24 Collection {
26 database: String,
27 collection: String,
28 },
29 Database { database: String },
31 #[default]
33 Cluster,
34}
35
36#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
38#[serde(tag = "type", rename_all = "snake_case")]
39pub enum StartFrom {
40 #[default]
42 Now,
43 Earliest,
45 ResumeToken { token: Value },
47 Timestamp { timestamp_secs: u32 },
49}
50
51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
53#[serde(rename_all = "snake_case")]
54pub enum FullDocument {
55 #[default]
57 Off,
58 WhenAvailable,
60 Required,
62 UpdateLookup,
64}
65
66#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
68#[serde(rename_all = "snake_case")]
69pub enum FullDocumentBeforeChange {
70 #[default]
72 Off,
73 WhenAvailable,
75 Required,
77}
78
79#[derive(Clone, Serialize, Deserialize, JsonSchema)]
81pub struct MongoCdcSourceConfig {
82 pub connection_uri: String,
84 #[serde(default)]
86 pub scope: Scope,
87 #[serde(default)]
90 pub operation_types: Vec<String>,
91 #[serde(default)]
93 pub full_document: FullDocument,
94 #[serde(default)]
96 pub full_document_before_change: FullDocumentBeforeChange,
97 #[serde(default)]
100 pub start_from: StartFrom,
101 #[serde(default)]
104 pub aggregation_pipeline: Vec<Value>,
105 #[serde(
108 default = "default_idle_timeout",
109 with = "faucet_core::config::duration_secs"
110 )]
111 #[schemars(with = "u64")]
112 pub idle_timeout: Duration,
113 #[serde(default = "default_max_await_time_ms")]
115 pub max_await_time_ms: u64,
116 #[serde(default = "default_batch_size")]
118 pub batch_size: usize,
119 #[serde(default)]
127 pub max_staged_records: Option<usize>,
128}
129
130impl MongoCdcSourceConfig {
131 pub fn validate(&self) -> Result<(), FaucetError> {
133 if self.connection_uri.trim().is_empty() {
134 return Err(FaucetError::Config(
135 "mongodb-cdc: connection_uri must not be empty".into(),
136 ));
137 }
138 match &self.scope {
139 Scope::Collection {
140 database,
141 collection,
142 } => {
143 if database.trim().is_empty() || collection.trim().is_empty() {
144 return Err(FaucetError::Config(
145 "mongodb-cdc: scope.database and scope.collection must not be empty".into(),
146 ));
147 }
148 }
149 Scope::Database { database } => {
150 if database.trim().is_empty() {
151 return Err(FaucetError::Config(
152 "mongodb-cdc: scope.database must not be empty".into(),
153 ));
154 }
155 }
156 Scope::Cluster => {}
157 }
158 let idle_ms = self.idle_timeout.as_millis();
159 if idle_ms == 0 {
160 return Err(FaucetError::Config(
161 "mongodb-cdc: idle_timeout must be > 0".into(),
162 ));
163 }
164 if u128::from(self.max_await_time_ms) >= idle_ms {
165 return Err(FaucetError::Config(format!(
166 "mongodb-cdc: max_await_time_ms ({}) must be strictly less than \
167 idle_timeout ({}ms)",
168 self.max_await_time_ms, idle_ms
169 )));
170 }
171 faucet_core::validate_batch_size(self.batch_size)?;
172 let key = crate::state::state_key(&self.scope);
174 faucet_core::state::validate_state_key(&key)?;
175 Ok(())
176 }
177}
178
179impl fmt::Debug for MongoCdcSourceConfig {
180 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181 f.debug_struct("MongoCdcSourceConfig")
182 .field("connection_uri", &"***")
183 .field("scope", &self.scope)
184 .field("operation_types", &self.operation_types)
185 .field("full_document", &self.full_document)
186 .field(
187 "full_document_before_change",
188 &self.full_document_before_change,
189 )
190 .field("start_from", &self.start_from)
191 .field("aggregation_pipeline", &self.aggregation_pipeline)
192 .field("idle_timeout", &self.idle_timeout)
193 .field("max_await_time_ms", &self.max_await_time_ms)
194 .field("batch_size", &self.batch_size)
195 .field("max_staged_records", &self.max_staged_records)
196 .finish()
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use serde_json::json;
204
205 fn minimal() -> MongoCdcSourceConfig {
206 serde_json::from_value(json!({
207 "connection_uri": "mongodb://localhost:27017/?replicaSet=rs0",
208 "scope": { "type": "collection", "database": "app", "collection": "users" }
209 }))
210 .unwrap()
211 }
212
213 #[test]
214 fn defaults_via_serde() {
215 let c = minimal();
216 assert_eq!(c.idle_timeout.as_secs(), 30);
217 assert_eq!(c.max_await_time_ms, 1000);
218 assert_eq!(c.batch_size, DEFAULT_BATCH_SIZE);
219 assert_eq!(c.start_from, StartFrom::Now);
220 assert_eq!(c.full_document, FullDocument::Off);
221 assert!(c.operation_types.is_empty());
222 assert_eq!(c.max_staged_records, None);
224 }
225
226 #[test]
227 fn max_staged_records_round_trips() {
228 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
229 "connection_uri": "mongodb://h/?replicaSet=rs0",
230 "scope": { "type": "cluster" },
231 "max_staged_records": 100000
232 }))
233 .unwrap();
234 assert_eq!(c.max_staged_records, Some(100_000));
235 assert!(c.validate().is_ok());
236 }
237
238 #[test]
239 fn scope_tagged_enum_round_trips() {
240 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
241 "connection_uri": "mongodb://h/?replicaSet=rs0",
242 "scope": { "type": "cluster" }
243 }))
244 .unwrap();
245 assert_eq!(c.scope, Scope::Cluster);
246 }
247
248 #[test]
249 fn validate_rejects_empty_uri() {
250 let mut c = minimal();
251 c.connection_uri = " ".into();
252 assert!(c.validate().is_err());
253 }
254
255 #[test]
256 fn validate_rejects_max_await_ge_idle() {
257 let mut c = minimal();
258 c.idle_timeout = Duration::from_millis(500);
259 c.max_await_time_ms = 500;
260 assert!(c.validate().is_err());
261 }
262
263 #[test]
264 fn validate_rejects_empty_collection() {
265 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
266 "connection_uri": "mongodb://h/?replicaSet=rs0",
267 "scope": { "type": "collection", "database": "app", "collection": "" }
268 }))
269 .unwrap();
270 assert!(c.validate().is_err());
271 }
272
273 #[test]
274 fn validate_rejects_empty_database_scope() {
275 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
276 "connection_uri": "mongodb://h/?replicaSet=rs0",
277 "scope": { "type": "database", "database": "" }
278 }))
279 .unwrap();
280 assert!(c.validate().is_err());
281 }
282
283 #[test]
284 fn validate_accepts_batch_size_zero_sentinel() {
285 let mut c = minimal();
286 c.batch_size = 0;
287 assert!(c.validate().is_ok());
288 }
289
290 #[test]
291 fn validate_accepts_minimal() {
292 assert!(minimal().validate().is_ok());
293 }
294
295 #[test]
296 fn debug_redacts_connection_uri() {
297 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
298 "connection_uri": "mongodb://user:secret@h:27017/?replicaSet=rs0",
299 "scope": { "type": "cluster" }
300 }))
301 .unwrap();
302 let dbg = format!("{c:?}");
303 assert!(dbg.contains("***"));
304 assert!(!dbg.contains("secret"));
305 }
306
307 #[test]
308 fn schema_includes_connection_uri_required() {
309 let schema = schemars::schema_for!(MongoCdcSourceConfig);
310 let v = serde_json::to_value(&schema).unwrap();
311 let req = v["required"].as_array().unwrap();
312 let names: Vec<_> = req.iter().filter_map(|x| x.as_str()).collect();
313 assert!(names.contains(&"connection_uri"));
314 }
315}