1use std::collections::HashMap;
7use std::time::Duration;
8
9use rdkafka::ClientConfig;
10
11use crate::config::ConnectorConfig;
12use crate::error::ConnectorError;
13use crate::kafka::config::{CompatibilityLevel, SaslMechanism, SecurityProtocol, SrAuth};
14use crate::serde::Format;
15
16const MAX_DELIVERY_TIMEOUT: Duration = Duration::from_secs(300);
17
18#[derive(Clone)]
25pub struct KafkaSinkConfig {
26 pub bootstrap_servers: String,
28 pub topic: String,
30 pub security_protocol: SecurityProtocol,
32 pub sasl_mechanism: Option<SaslMechanism>,
34 pub sasl_username: Option<String>,
36 pub sasl_password: Option<String>,
38 pub ssl_ca_location: Option<String>,
40 pub ssl_certificate_location: Option<String>,
42 pub ssl_key_location: Option<String>,
44 pub ssl_key_password: Option<String>,
46 pub format: Format,
48 pub schema_registry_url: Option<String>,
50 pub schema_registry_auth: Option<SrAuth>,
52 pub schema_compatibility: Option<CompatibilityLevel>,
54 pub schema_registry_ssl_ca_location: Option<String>,
56 pub max_in_flight: usize,
58 pub delivery_timeout: Duration,
60 pub key_column: Option<String>,
63 pub envelope: SinkEnvelope,
65 pub partitioner: PartitionStrategy,
67 pub linger_ms: u64,
69 pub batch_size: usize,
71 pub batch_num_messages: Option<usize>,
73 pub compression: CompressionType,
75 pub dlq_topic: Option<String>,
77 pub kafka_properties: HashMap<String, String>,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
83pub enum SinkEnvelope {
84 #[default]
87 Append,
88 Upsert,
91}
92
93impl std::fmt::Debug for KafkaSinkConfig {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 f.debug_struct("KafkaSinkConfig")
96 .field("bootstrap_servers", &self.bootstrap_servers)
97 .field("topic", &self.topic)
98 .field("format", &self.format)
99 .field("security_protocol", &self.security_protocol)
100 .field("sasl_mechanism", &self.sasl_mechanism)
101 .field("sasl_password", &self.sasl_password.as_ref().map(|_| "***"))
102 .field(
103 "ssl_key_password",
104 &self.ssl_key_password.as_ref().map(|_| "***"),
105 )
106 .field("partitioner", &self.partitioner)
107 .finish_non_exhaustive()
108 }
109}
110
111impl Default for KafkaSinkConfig {
112 fn default() -> Self {
113 Self {
114 bootstrap_servers: String::new(),
115 topic: String::new(),
116 security_protocol: SecurityProtocol::default(),
117 sasl_mechanism: None,
118 sasl_username: None,
119 sasl_password: None,
120 ssl_ca_location: None,
121 ssl_certificate_location: None,
122 ssl_key_location: None,
123 ssl_key_password: None,
124 format: Format::Json,
125 schema_registry_url: None,
126 schema_registry_auth: None,
127 schema_compatibility: None,
128 schema_registry_ssl_ca_location: None,
129 max_in_flight: 5,
130 delivery_timeout: Duration::from_secs(120),
131 key_column: None,
132 envelope: SinkEnvelope::default(),
133 partitioner: PartitionStrategy::KeyHash,
134 linger_ms: 5,
135 batch_size: 16_384,
136 batch_num_messages: None,
137 compression: CompressionType::None,
138 dlq_topic: None,
139 kafka_properties: HashMap::new(),
140 }
141 }
142}
143
144impl KafkaSinkConfig {
145 #[allow(clippy::too_many_lines, clippy::field_reassign_with_default)]
152 pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
153 let mut cfg = Self::default();
154
155 cfg.bootstrap_servers = config
156 .get("bootstrap.servers")
157 .ok_or_else(|| ConnectorError::missing_config("bootstrap.servers"))?
158 .to_string();
159
160 cfg.topic = config
161 .get("topic")
162 .ok_or_else(|| ConnectorError::missing_config("topic"))?
163 .to_string();
164
165 if let Some(s) = config.get("security.protocol") {
166 cfg.security_protocol = s.parse()?;
167 }
168
169 if let Some(s) = config.get("sasl.mechanism") {
170 cfg.sasl_mechanism = Some(s.parse()?);
171 }
172
173 cfg.sasl_username = config.get("sasl.username").map(String::from);
174 cfg.sasl_password = config.get("sasl.password").map(String::from);
175 cfg.ssl_ca_location = config.get("ssl.ca.location").map(String::from);
176 cfg.ssl_certificate_location = config.get("ssl.certificate.location").map(String::from);
177 cfg.ssl_key_location = config.get("ssl.key.location").map(String::from);
178 cfg.ssl_key_password = config.get("ssl.key.password").map(String::from);
179
180 if let Some(fmt) = config.get("format") {
181 cfg.format = fmt.parse().map_err(ConnectorError::Serde)?;
182 }
183
184 cfg.schema_registry_url = config.get("schema.registry.url").map(String::from);
185
186 let sr_user = config.get("schema.registry.username");
187 let sr_pass = config.get("schema.registry.password");
188 if let (Some(user), Some(pass)) = (sr_user, sr_pass) {
189 cfg.schema_registry_auth = Some(SrAuth {
190 username: user.to_string(),
191 password: pass.to_string(),
192 });
193 }
194
195 if let Some(c) = config.get("schema.compatibility") {
196 cfg.schema_compatibility = Some(c.parse().map_err(|_| {
197 ConnectorError::ConfigurationError(format!("invalid schema.compatibility: '{c}'"))
198 })?);
199 }
200
201 cfg.schema_registry_ssl_ca_location = config
202 .get("schema.registry.ssl.ca.location")
203 .map(String::from);
204
205 if config.get("acks").is_some() {
206 return Err(ConnectorError::ConfigurationError(
207 "'acks' is not configurable; the durable Kafka sink always requires acks=all"
208 .into(),
209 ));
210 }
211
212 if let Some(v) = config.get("max.in.flight.requests") {
213 cfg.max_in_flight = v.parse().map_err(|_| {
214 ConnectorError::ConfigurationError(format!("invalid max.in.flight.requests: '{v}'"))
215 })?;
216 }
217
218 if let Some(v) = config.get("delivery.timeout.ms") {
219 let ms: u64 = v.parse().map_err(|_| {
220 ConnectorError::ConfigurationError(format!("invalid delivery.timeout.ms: '{v}'"))
221 })?;
222 cfg.delivery_timeout = Duration::from_millis(ms);
223 }
224
225 cfg.key_column = config.get("key.column").map(String::from);
226
227 cfg.envelope = match config
228 .get("envelope")
229 .map(str::to_ascii_lowercase)
230 .as_deref()
231 {
232 None | Some("append") => SinkEnvelope::Append,
233 Some("upsert") => SinkEnvelope::Upsert,
234 Some(other) => {
235 return Err(ConnectorError::ConfigurationError(format!(
236 "unknown envelope '{other}' (expected 'append' or 'upsert')"
237 )));
238 }
239 };
240 if cfg.envelope == SinkEnvelope::Upsert
241 && cfg
242 .key_column
243 .as_deref()
244 .is_none_or(|k| k.trim().is_empty())
245 {
246 return Err(ConnectorError::ConfigurationError(
247 "envelope = 'upsert' requires a non-empty 'key.column' (the merge key)".into(),
248 ));
249 }
250
251 if let Some(p) = config.get("partitioner") {
252 cfg.partitioner = p.parse().map_err(|_| {
253 ConnectorError::ConfigurationError(format!(
254 "invalid partitioner: '{p}' (expected 'key-hash', 'round-robin', or 'sticky')"
255 ))
256 })?;
257 }
258
259 if let Some(v) = config.get("linger.ms") {
260 cfg.linger_ms = v.parse().map_err(|_| {
261 ConnectorError::ConfigurationError(format!("invalid linger.ms: '{v}'"))
262 })?;
263 }
264
265 if let Some(v) = config.get("batch.size") {
266 cfg.batch_size = v.parse().map_err(|_| {
267 ConnectorError::ConfigurationError(format!("invalid batch.size: '{v}'"))
268 })?;
269 }
270
271 if let Some(v) = config.get("batch.num.messages") {
272 cfg.batch_num_messages = Some(v.parse().map_err(|_| {
273 ConnectorError::ConfigurationError(format!("invalid batch.num.messages: '{v}'"))
274 })?);
275 }
276
277 if let Some(c) = config.get("compression.type") {
278 cfg.compression = c.parse().map_err(|_| {
279 ConnectorError::ConfigurationError(format!("invalid compression.type: '{c}'"))
280 })?;
281 }
282
283 cfg.dlq_topic = config.get("dlq.topic").map(String::from);
284 if cfg.dlq_topic.is_some() && cfg.envelope == SinkEnvelope::Upsert {
285 return Err(ConnectorError::ConfigurationError(
288 "'dlq.topic' is not supported with envelope = 'upsert' (upsert failures poison the \
289 epoch instead of routing to a DLQ)"
290 .into(),
291 ));
292 }
293
294 for (key, value) in config.properties_with_prefix("kafka.") {
295 cfg.kafka_properties.insert(key, value);
296 }
297
298 cfg.validate()?;
299 Ok(cfg)
300 }
301
302 pub fn validate(&self) -> Result<(), ConnectorError> {
308 if self.bootstrap_servers.is_empty() {
309 return Err(ConnectorError::missing_config("bootstrap.servers"));
310 }
311 if self.topic.is_empty() {
312 return Err(ConnectorError::missing_config("topic"));
313 }
314
315 if self.security_protocol.uses_sasl() && self.sasl_mechanism.is_none() {
316 return Err(ConnectorError::ConfigurationError(
317 "sasl.mechanism is required when security.protocol is sasl_plaintext or sasl_ssl"
318 .into(),
319 ));
320 }
321
322 if let Some(mechanism) = &self.sasl_mechanism {
323 if mechanism.requires_credentials()
324 && (self.sasl_username.is_none() || self.sasl_password.is_none())
325 {
326 return Err(ConnectorError::ConfigurationError(format!(
327 "sasl.username and sasl.password are required for {mechanism} mechanism"
328 )));
329 }
330 }
331
332 if self.security_protocol.uses_ssl() {
333 if let Some(ref ca) = self.ssl_ca_location {
334 if ca.is_empty() {
335 return Err(ConnectorError::ConfigurationError(
336 "ssl.ca.location cannot be empty when specified".into(),
337 ));
338 }
339 }
340 }
341
342 if self.format == Format::Debezium {
343 return Err(ConnectorError::ConfigurationError(
344 "Debezium is a deserialization-only format and cannot be used for sinks".into(),
345 ));
346 }
347
348 if self.format == Format::Avro && self.schema_registry_url.is_none() {
349 return Err(ConnectorError::ConfigurationError(
350 "Avro format requires 'schema.registry.url'".into(),
351 ));
352 }
353
354 if !(1..=5).contains(&self.max_in_flight) {
355 return Err(ConnectorError::ConfigurationError(
356 "max.in.flight.requests must be between 1 and 5 when idempotence is enabled".into(),
357 ));
358 }
359
360 if self.delivery_timeout.is_zero() {
361 return Err(ConnectorError::ConfigurationError(
362 "delivery.timeout.ms must be > 0; zero disables librdkafka's delivery deadline"
363 .into(),
364 ));
365 }
366 if self.delivery_timeout > MAX_DELIVERY_TIMEOUT {
367 return Err(ConnectorError::ConfigurationError(format!(
368 "delivery.timeout.ms must be <= {}",
369 MAX_DELIVERY_TIMEOUT.as_millis()
370 )));
371 }
372
373 Ok(())
374 }
375
376 #[must_use]
380 pub fn to_rdkafka_config(&self) -> ClientConfig {
381 let mut config = ClientConfig::new();
382
383 config.set("bootstrap.servers", &self.bootstrap_servers);
384 config.set("security.protocol", self.security_protocol.as_rdkafka_str());
385
386 if let Some(ref mechanism) = self.sasl_mechanism {
387 config.set("sasl.mechanism", mechanism.as_rdkafka_str());
388 }
389
390 if let Some(ref username) = self.sasl_username {
391 config.set("sasl.username", username);
392 }
393
394 if let Some(ref password) = self.sasl_password {
395 config.set("sasl.password", password);
396 }
397
398 if let Some(ref ca) = self.ssl_ca_location {
399 config.set("ssl.ca.location", ca);
400 }
401
402 if let Some(ref cert) = self.ssl_certificate_location {
403 config.set("ssl.certificate.location", cert);
404 }
405
406 if let Some(ref key) = self.ssl_key_location {
407 config.set("ssl.key.location", key);
408 }
409
410 if let Some(ref key_pass) = self.ssl_key_password {
411 config.set("ssl.key.password", key_pass);
412 }
413
414 config
415 .set("enable.idempotence", "true")
416 .set("acks", "all")
417 .set("linger.ms", self.linger_ms.to_string())
418 .set("batch.size", self.batch_size.to_string())
419 .set("compression.type", self.compression.as_rdkafka_str())
420 .set(
421 "max.in.flight.requests.per.connection",
422 self.max_in_flight.to_string(),
423 )
424 .set(
425 "message.timeout.ms",
426 self.delivery_timeout.as_millis().to_string(),
427 );
428
429 if let Some(num_msgs) = self.batch_num_messages {
430 config.set("batch.num.messages", num_msgs.to_string());
431 }
432
433 for (key, value) in &self.kafka_properties {
436 if is_blocked_passthrough_key(key) {
437 tracing::warn!(
438 key,
439 "ignoring kafka.* pass-through property that overrides a protected connector setting"
440 );
441 continue;
442 }
443 config.set(key, value);
444 }
445
446 config
447 }
448
449 #[must_use]
454 pub fn to_dlq_rdkafka_config(&self) -> ClientConfig {
455 let mut config = ClientConfig::new();
456
457 config.set("bootstrap.servers", &self.bootstrap_servers);
458 config.set("security.protocol", self.security_protocol.as_rdkafka_str());
459
460 if let Some(ref mechanism) = self.sasl_mechanism {
461 config.set("sasl.mechanism", mechanism.as_rdkafka_str());
462 }
463 if let Some(ref username) = self.sasl_username {
464 config.set("sasl.username", username);
465 }
466 if let Some(ref password) = self.sasl_password {
467 config.set("sasl.password", password);
468 }
469 if let Some(ref ca) = self.ssl_ca_location {
470 config.set("ssl.ca.location", ca);
471 }
472 if let Some(ref cert) = self.ssl_certificate_location {
473 config.set("ssl.certificate.location", cert);
474 }
475 if let Some(ref key) = self.ssl_key_location {
476 config.set("ssl.key.location", key);
477 }
478 if let Some(ref key_pass) = self.ssl_key_password {
479 config.set("ssl.key.password", key_pass);
480 }
481
482 config
483 .set("enable.idempotence", "true")
484 .set("acks", "all")
485 .set(
486 "max.in.flight.requests.per.connection",
487 self.max_in_flight.to_string(),
488 )
489 .set(
490 "message.timeout.ms",
491 self.delivery_timeout.as_millis().to_string(),
492 );
493
494 config
495 }
496}
497
498fn is_blocked_passthrough_key(key: &str) -> bool {
500 key.starts_with("sasl.kerberos.")
501 || matches!(
502 key,
503 "security.protocol"
504 | "sasl.mechanism"
505 | "sasl.username"
506 | "sasl.password"
507 | "sasl.oauthbearer.config"
508 | "ssl.ca.location"
509 | "ssl.certificate.location"
510 | "ssl.key.location"
511 | "ssl.key.password"
512 | "ssl.endpoint.identification.algorithm"
513 | "enable.auto.commit"
514 | "enable.idempotence"
515 | "acks"
516 | "max.in.flight"
517 | "max.in.flight.requests.per.connection"
518 | "message.timeout.ms"
519 | "delivery.timeout.ms"
520 | "transactional.id"
521 )
522}
523
524#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526pub enum PartitionStrategy {
527 KeyHash,
529 RoundRobin,
531 Sticky,
533}
534
535str_enum!(PartitionStrategy, lowercase_udash, String, "unknown partition strategy",
536 KeyHash => "key-hash", "keyhash", "hash";
537 RoundRobin => "round-robin", "roundrobin";
538 Sticky => "sticky"
539);
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq)]
543pub enum CompressionType {
544 None,
546 Gzip,
548 Snappy,
550 Lz4,
552 Zstd,
554}
555
556impl CompressionType {
557 #[must_use]
559 pub fn as_rdkafka_str(&self) -> &'static str {
560 match self {
561 Self::None => "none",
562 Self::Gzip => "gzip",
563 Self::Snappy => "snappy",
564 Self::Lz4 => "lz4",
565 Self::Zstd => "zstd",
566 }
567 }
568}
569
570str_enum!(fromstr CompressionType, lowercase_nodash, String, "unknown compression type",
571 None => "none";
572 Gzip => "gzip";
573 Snappy => "snappy";
574 Lz4 => "lz4";
575 Zstd => "zstd", "zstandard"
576);
577
578impl std::fmt::Display for CompressionType {
579 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
580 write!(f, "{}", self.as_rdkafka_str())
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 fn make_config(pairs: &[(&str, &str)]) -> ConnectorConfig {
589 let mut config = ConnectorConfig::new("kafka");
590 for (k, v) in pairs {
591 config.set(*k, *v);
592 }
593 config
594 }
595
596 fn required_pairs() -> Vec<(&'static str, &'static str)> {
597 vec![
598 ("bootstrap.servers", "localhost:9092"),
599 ("topic", "output-events"),
600 ]
601 }
602
603 #[test]
604 fn test_parse_required_fields() {
605 let config = make_config(&required_pairs());
606 let cfg = KafkaSinkConfig::from_config(&config).unwrap();
607 assert_eq!(cfg.bootstrap_servers, "localhost:9092");
608 assert_eq!(cfg.topic, "output-events");
609 assert_eq!(cfg.format, Format::Json);
610 assert_eq!(cfg.security_protocol, SecurityProtocol::Plaintext);
611 }
612
613 #[test]
614 fn test_missing_bootstrap_servers() {
615 let config = make_config(&[("topic", "t")]);
616 assert!(KafkaSinkConfig::from_config(&config).is_err());
617 }
618
619 #[test]
620 fn test_missing_topic() {
621 let config = make_config(&[("bootstrap.servers", "b:9092")]);
622 assert!(KafkaSinkConfig::from_config(&config).is_err());
623 }
624
625 #[test]
626 fn test_parse_security_sasl_ssl() {
627 let mut pairs = required_pairs();
628 pairs.extend_from_slice(&[
629 ("security.protocol", "sasl_ssl"),
630 ("sasl.mechanism", "SCRAM-SHA-512"),
631 ("sasl.username", "producer"),
632 ("sasl.password", "secret123"),
633 ("ssl.ca.location", "/etc/ssl/ca.pem"),
634 ]);
635 let config = make_config(&pairs);
636 let cfg = KafkaSinkConfig::from_config(&config).unwrap();
637
638 assert_eq!(cfg.security_protocol, SecurityProtocol::SaslSsl);
639 assert_eq!(cfg.sasl_mechanism, Some(SaslMechanism::ScramSha512));
640 assert_eq!(cfg.sasl_username, Some("producer".to_string()));
641 assert_eq!(cfg.sasl_password, Some("secret123".to_string()));
642 assert_eq!(cfg.ssl_ca_location, Some("/etc/ssl/ca.pem".to_string()));
643 }
644
645 #[test]
646 fn test_parse_security_ssl_only() {
647 let mut pairs = required_pairs();
648 pairs.extend_from_slice(&[
649 ("security.protocol", "ssl"),
650 ("ssl.ca.location", "/etc/ssl/ca.pem"),
651 ("ssl.certificate.location", "/etc/ssl/client.pem"),
652 ("ssl.key.location", "/etc/ssl/client.key"),
653 ("ssl.key.password", "keypass"),
654 ]);
655 let config = make_config(&pairs);
656 let cfg = KafkaSinkConfig::from_config(&config).unwrap();
657
658 assert_eq!(cfg.security_protocol, SecurityProtocol::Ssl);
659 assert_eq!(cfg.ssl_ca_location, Some("/etc/ssl/ca.pem".to_string()));
660 assert_eq!(
661 cfg.ssl_certificate_location,
662 Some("/etc/ssl/client.pem".to_string())
663 );
664 assert_eq!(
665 cfg.ssl_key_location,
666 Some("/etc/ssl/client.key".to_string())
667 );
668 assert_eq!(cfg.ssl_key_password, Some("keypass".to_string()));
669 }
670
671 #[test]
672 fn test_parse_all_optional_fields() {
673 let mut pairs = required_pairs();
674 pairs.extend_from_slice(&[
675 ("format", "avro"),
676 ("key.column", "order_id"),
677 ("partitioner", "round-robin"),
678 ("linger.ms", "10"),
679 ("batch.size", "32768"),
680 ("batch.num.messages", "5000"),
681 ("compression.type", "zstd"),
682 ("max.in.flight.requests", "3"),
683 ("delivery.timeout.ms", "60000"),
684 ("dlq.topic", "my-dlq"),
685 ("schema.registry.url", "http://sr:8081"),
686 ("schema.registry.username", "user"),
687 ("schema.registry.password", "pass"),
688 ("schema.registry.ssl.ca.location", "/etc/ssl/sr-ca.pem"),
689 ]);
690 let config = make_config(&pairs);
691 let cfg = KafkaSinkConfig::from_config(&config).unwrap();
692
693 assert_eq!(cfg.format, Format::Avro);
694 assert_eq!(cfg.key_column.as_deref(), Some("order_id"));
695 assert_eq!(cfg.partitioner, PartitionStrategy::RoundRobin);
696 assert_eq!(cfg.linger_ms, 10);
697 assert_eq!(cfg.batch_size, 32_768);
698 assert_eq!(cfg.batch_num_messages, Some(5000));
699 assert_eq!(cfg.compression, CompressionType::Zstd);
700 assert_eq!(cfg.max_in_flight, 3);
701 assert_eq!(cfg.delivery_timeout, Duration::from_secs(60));
702 assert_eq!(cfg.dlq_topic.as_deref(), Some("my-dlq"));
703 assert_eq!(cfg.schema_registry_url.as_deref(), Some("http://sr:8081"));
704 assert!(cfg.schema_registry_auth.is_some());
705 assert_eq!(
706 cfg.schema_registry_ssl_ca_location,
707 Some("/etc/ssl/sr-ca.pem".to_string())
708 );
709 }
710
711 #[test]
712 fn delivery_timeout_rejects_infinite_or_excessive_values() {
713 for value in ["0", "300001"] {
714 let mut pairs = required_pairs();
715 pairs.push(("delivery.timeout.ms", value));
716 let error = KafkaSinkConfig::from_config(&make_config(&pairs)).unwrap_err();
717 assert!(
718 matches!(error, ConnectorError::ConfigurationError(_)),
719 "unexpected error for delivery.timeout.ms={value}: {error}"
720 );
721 }
722 }
723
724 #[test]
725 fn delivery_timeout_accepts_fixed_maximum() {
726 let mut pairs = required_pairs();
727 pairs.push(("delivery.timeout.ms", "300000"));
728 let cfg = KafkaSinkConfig::from_config(&make_config(&pairs)).unwrap();
729 assert_eq!(cfg.delivery_timeout, MAX_DELIVERY_TIMEOUT);
730 assert_eq!(
731 cfg.to_rdkafka_config().get("message.timeout.ms"),
732 Some("300000")
733 );
734 assert_eq!(
735 cfg.to_dlq_rdkafka_config().get("message.timeout.ms"),
736 Some("300000")
737 );
738 }
739
740 #[test]
741 fn test_validate_avro_requires_sr() {
742 let mut cfg = KafkaSinkConfig::default();
743 cfg.bootstrap_servers = "b:9092".into();
744 cfg.topic = "t".into();
745 cfg.format = Format::Avro;
746 assert!(cfg.validate().is_err());
747 }
748
749 #[test]
750 fn test_validate_sasl_without_mechanism() {
751 let mut cfg = KafkaSinkConfig::default();
752 cfg.bootstrap_servers = "b:9092".into();
753 cfg.topic = "t".into();
754 cfg.security_protocol = SecurityProtocol::SaslSsl;
755 assert!(cfg.validate().is_err());
757 }
758
759 #[test]
760 fn test_validate_sasl_plain_without_credentials() {
761 let mut cfg = KafkaSinkConfig::default();
762 cfg.bootstrap_servers = "b:9092".into();
763 cfg.topic = "t".into();
764 cfg.security_protocol = SecurityProtocol::SaslPlaintext;
765 cfg.sasl_mechanism = Some(SaslMechanism::ScramSha256);
766 assert!(cfg.validate().is_err());
768 }
769
770 #[test]
771 fn test_rdkafka_config_at_least_once() {
772 let mut cfg = KafkaSinkConfig::default();
773 cfg.bootstrap_servers = "b:9092".into();
774 cfg.topic = "t".into();
775 let rdk = cfg.to_rdkafka_config();
776 assert_eq!(rdk.get("enable.idempotence"), Some("true"));
777 assert_eq!(rdk.get("acks"), Some("all"));
778 assert_eq!(rdk.get("max.in.flight.requests.per.connection"), Some("5"));
779 assert!(rdk.get("transactional.id").is_none());
780 assert_eq!(rdk.get("security.protocol"), Some("plaintext"));
781
782 let dlq = cfg.to_dlq_rdkafka_config();
783 assert_eq!(dlq.get("enable.idempotence"), Some("true"));
784 assert_eq!(dlq.get("acks"), Some("all"));
785 assert_eq!(dlq.get("max.in.flight.requests.per.connection"), Some("5"));
786 }
787
788 #[test]
789 fn acks_option_is_rejected_instead_of_downgrading_durability() {
790 for value in ["0", "1", "all"] {
791 let mut pairs = required_pairs();
792 pairs.push(("acks", value));
793 let error = KafkaSinkConfig::from_config(&make_config(&pairs)).unwrap_err();
794 assert!(matches!(error, ConnectorError::ConfigurationError(_)));
795 assert!(error.to_string().contains("always requires acks=all"));
796 }
797 }
798
799 #[test]
800 fn idempotent_producer_rejects_invalid_in_flight_boundaries() {
801 for value in ["0", "6", "100"] {
802 let mut pairs = required_pairs();
803 pairs.push(("max.in.flight.requests", value));
804 let error = KafkaSinkConfig::from_config(&make_config(&pairs)).unwrap_err();
805 assert!(matches!(error, ConnectorError::ConfigurationError(_)));
806 }
807 for value in ["1", "5"] {
808 let mut pairs = required_pairs();
809 pairs.push(("max.in.flight.requests", value));
810 assert!(KafkaSinkConfig::from_config(&make_config(&pairs)).is_ok());
811 }
812 }
813
814 #[test]
815 fn test_rdkafka_config_with_security() {
816 let mut cfg = KafkaSinkConfig::default();
817 cfg.bootstrap_servers = "b:9092".into();
818 cfg.topic = "t".into();
819 cfg.security_protocol = SecurityProtocol::SaslSsl;
820 cfg.sasl_mechanism = Some(SaslMechanism::Plain);
821 cfg.sasl_username = Some("user".into());
822 cfg.sasl_password = Some("pass".into());
823 cfg.ssl_ca_location = Some("/ca.pem".into());
824
825 let rdk = cfg.to_rdkafka_config();
826 assert_eq!(rdk.get("security.protocol"), Some("sasl_ssl"));
827 assert_eq!(rdk.get("sasl.mechanism"), Some("PLAIN"));
828 assert_eq!(rdk.get("sasl.username"), Some("user"));
829 assert_eq!(rdk.get("sasl.password"), Some("pass"));
830 assert_eq!(rdk.get("ssl.ca.location"), Some("/ca.pem"));
831 }
832
833 #[test]
834 fn test_rdkafka_config_with_batch_num_messages() {
835 let mut cfg = KafkaSinkConfig::default();
836 cfg.bootstrap_servers = "b:9092".into();
837 cfg.topic = "t".into();
838 cfg.batch_num_messages = Some(10_000);
839
840 let rdk = cfg.to_rdkafka_config();
841 assert_eq!(rdk.get("batch.num.messages"), Some("10000"));
842 }
843
844 #[test]
845 fn test_kafka_passthrough_properties() {
846 let mut pairs = required_pairs();
847 pairs.push(("kafka.socket.timeout.ms", "5000"));
848 pairs.push(("kafka.queue.buffering.max.messages", "100000"));
849 let config = make_config(&pairs);
850 let cfg = KafkaSinkConfig::from_config(&config).unwrap();
851 assert_eq!(
852 cfg.kafka_properties.get("socket.timeout.ms").unwrap(),
853 "5000"
854 );
855 }
856
857 #[test]
858 fn passthrough_cannot_disable_delivery_deadline() {
859 let mut pairs = required_pairs();
860 pairs.push(("kafka.message.timeout.ms", "0"));
861 pairs.push(("kafka.delivery.timeout.ms", "0"));
862 let config = make_config(&pairs);
863 let cfg = KafkaSinkConfig::from_config(&config).unwrap();
864
865 assert_eq!(
866 cfg.to_rdkafka_config().get("message.timeout.ms"),
867 Some("120000")
868 );
869 assert_eq!(
870 cfg.to_dlq_rdkafka_config().get("message.timeout.ms"),
871 Some("120000")
872 );
873 }
874
875 #[test]
876 fn passthrough_cannot_override_idempotent_delivery_invariants() {
877 let mut pairs = required_pairs();
878 pairs.push(("kafka.acks", "0"));
879 pairs.push(("kafka.max.in.flight", "99"));
880 pairs.push(("kafka.max.in.flight.requests.per.connection", "99"));
881 let cfg = KafkaSinkConfig::from_config(&make_config(&pairs)).unwrap();
882
883 for rdk in [cfg.to_rdkafka_config(), cfg.to_dlq_rdkafka_config()] {
884 assert_eq!(rdk.get("acks"), Some("all"));
885 assert_eq!(rdk.get("max.in.flight.requests.per.connection"), Some("5"));
886 }
887 }
888
889 #[test]
890 fn test_defaults() {
891 let cfg = KafkaSinkConfig::default();
892 assert_eq!(cfg.partitioner, PartitionStrategy::KeyHash);
893 assert_eq!(cfg.compression, CompressionType::None);
894 assert_eq!(cfg.linger_ms, 5);
895 assert_eq!(cfg.batch_size, 16_384);
896 assert_eq!(cfg.max_in_flight, 5);
897 assert_eq!(cfg.delivery_timeout, Duration::from_secs(120));
898 assert_eq!(cfg.security_protocol, SecurityProtocol::Plaintext);
899 assert!(cfg.sasl_mechanism.is_none());
900 assert!(cfg.batch_num_messages.is_none());
901 }
902
903 #[test]
904 fn test_enum_display() {
905 assert_eq!(PartitionStrategy::KeyHash.to_string(), "key-hash");
906 assert_eq!(PartitionStrategy::RoundRobin.to_string(), "round-robin");
907 assert_eq!(PartitionStrategy::Sticky.to_string(), "sticky");
908 assert_eq!(CompressionType::Zstd.to_string(), "zstd");
909 }
910
911 #[test]
912 fn test_enum_parse() {
913 assert_eq!(
914 "key-hash".parse::<PartitionStrategy>().unwrap(),
915 PartitionStrategy::KeyHash
916 );
917 assert_eq!(
918 "round-robin".parse::<PartitionStrategy>().unwrap(),
919 PartitionStrategy::RoundRobin
920 );
921 assert_eq!(
922 "sticky".parse::<PartitionStrategy>().unwrap(),
923 PartitionStrategy::Sticky
924 );
925 assert_eq!(
926 "gzip".parse::<CompressionType>().unwrap(),
927 CompressionType::Gzip
928 );
929 assert_eq!(
930 "snappy".parse::<CompressionType>().unwrap(),
931 CompressionType::Snappy
932 );
933 assert_eq!(
934 "lz4".parse::<CompressionType>().unwrap(),
935 CompressionType::Lz4
936 );
937 assert_eq!(
938 "zstd".parse::<CompressionType>().unwrap(),
939 CompressionType::Zstd
940 );
941 }
942}