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}
120
121impl MongoCdcSourceConfig {
122 pub fn validate(&self) -> Result<(), FaucetError> {
124 if self.connection_uri.trim().is_empty() {
125 return Err(FaucetError::Config(
126 "mongodb-cdc: connection_uri must not be empty".into(),
127 ));
128 }
129 match &self.scope {
130 Scope::Collection {
131 database,
132 collection,
133 } => {
134 if database.trim().is_empty() || collection.trim().is_empty() {
135 return Err(FaucetError::Config(
136 "mongodb-cdc: scope.database and scope.collection must not be empty".into(),
137 ));
138 }
139 }
140 Scope::Database { database } => {
141 if database.trim().is_empty() {
142 return Err(FaucetError::Config(
143 "mongodb-cdc: scope.database must not be empty".into(),
144 ));
145 }
146 }
147 Scope::Cluster => {}
148 }
149 let idle_ms = self.idle_timeout.as_millis();
150 if idle_ms == 0 {
151 return Err(FaucetError::Config(
152 "mongodb-cdc: idle_timeout must be > 0".into(),
153 ));
154 }
155 if u128::from(self.max_await_time_ms) >= idle_ms {
156 return Err(FaucetError::Config(format!(
157 "mongodb-cdc: max_await_time_ms ({}) must be strictly less than \
158 idle_timeout ({}ms)",
159 self.max_await_time_ms, idle_ms
160 )));
161 }
162 faucet_core::validate_batch_size(self.batch_size)?;
163 let key = crate::state::state_key(&self.scope);
165 faucet_core::state::validate_state_key(&key)?;
166 Ok(())
167 }
168}
169
170impl fmt::Debug for MongoCdcSourceConfig {
171 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172 f.debug_struct("MongoCdcSourceConfig")
173 .field("connection_uri", &"***")
174 .field("scope", &self.scope)
175 .field("operation_types", &self.operation_types)
176 .field("full_document", &self.full_document)
177 .field(
178 "full_document_before_change",
179 &self.full_document_before_change,
180 )
181 .field("start_from", &self.start_from)
182 .field("aggregation_pipeline", &self.aggregation_pipeline)
183 .field("idle_timeout", &self.idle_timeout)
184 .field("max_await_time_ms", &self.max_await_time_ms)
185 .field("batch_size", &self.batch_size)
186 .finish()
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use serde_json::json;
194
195 fn minimal() -> MongoCdcSourceConfig {
196 serde_json::from_value(json!({
197 "connection_uri": "mongodb://localhost:27017/?replicaSet=rs0",
198 "scope": { "type": "collection", "database": "app", "collection": "users" }
199 }))
200 .unwrap()
201 }
202
203 #[test]
204 fn defaults_via_serde() {
205 let c = minimal();
206 assert_eq!(c.idle_timeout.as_secs(), 30);
207 assert_eq!(c.max_await_time_ms, 1000);
208 assert_eq!(c.batch_size, DEFAULT_BATCH_SIZE);
209 assert_eq!(c.start_from, StartFrom::Now);
210 assert_eq!(c.full_document, FullDocument::Off);
211 assert!(c.operation_types.is_empty());
212 }
213
214 #[test]
215 fn scope_tagged_enum_round_trips() {
216 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
217 "connection_uri": "mongodb://h/?replicaSet=rs0",
218 "scope": { "type": "cluster" }
219 }))
220 .unwrap();
221 assert_eq!(c.scope, Scope::Cluster);
222 }
223
224 #[test]
225 fn validate_rejects_empty_uri() {
226 let mut c = minimal();
227 c.connection_uri = " ".into();
228 assert!(c.validate().is_err());
229 }
230
231 #[test]
232 fn validate_rejects_max_await_ge_idle() {
233 let mut c = minimal();
234 c.idle_timeout = Duration::from_millis(500);
235 c.max_await_time_ms = 500;
236 assert!(c.validate().is_err());
237 }
238
239 #[test]
240 fn validate_rejects_empty_collection() {
241 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
242 "connection_uri": "mongodb://h/?replicaSet=rs0",
243 "scope": { "type": "collection", "database": "app", "collection": "" }
244 }))
245 .unwrap();
246 assert!(c.validate().is_err());
247 }
248
249 #[test]
250 fn validate_rejects_empty_database_scope() {
251 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
252 "connection_uri": "mongodb://h/?replicaSet=rs0",
253 "scope": { "type": "database", "database": "" }
254 }))
255 .unwrap();
256 assert!(c.validate().is_err());
257 }
258
259 #[test]
260 fn validate_accepts_batch_size_zero_sentinel() {
261 let mut c = minimal();
262 c.batch_size = 0;
263 assert!(c.validate().is_ok());
264 }
265
266 #[test]
267 fn validate_accepts_minimal() {
268 assert!(minimal().validate().is_ok());
269 }
270
271 #[test]
272 fn debug_redacts_connection_uri() {
273 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
274 "connection_uri": "mongodb://user:secret@h:27017/?replicaSet=rs0",
275 "scope": { "type": "cluster" }
276 }))
277 .unwrap();
278 let dbg = format!("{c:?}");
279 assert!(dbg.contains("***"));
280 assert!(!dbg.contains("secret"));
281 }
282
283 #[test]
284 fn schema_includes_connection_uri_required() {
285 let schema = schemars::schema_for!(MongoCdcSourceConfig);
286 let v = serde_json::to_value(&schema).unwrap();
287 let req = v["required"].as_array().unwrap();
288 let names: Vec<_> = req.iter().filter_map(|x| x.as_str()).collect();
289 assert!(names.contains(&"connection_uri"));
290 }
291}