1use std::collections::HashMap;
5use std::time::Duration;
6
7use rdkafka::config::ClientConfig;
8
9use crate::config::ConnectorConfig;
10use crate::error::ConnectorError;
11use crate::serde::Format;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum SecurityProtocol {
18 #[default]
20 Plaintext,
21 Ssl,
23 SaslPlaintext,
25 SaslSsl,
27}
28
29impl SecurityProtocol {
30 #[must_use]
32 pub fn as_rdkafka_str(&self) -> &'static str {
33 match self {
34 SecurityProtocol::Plaintext => "plaintext",
35 SecurityProtocol::Ssl => "ssl",
36 SecurityProtocol::SaslPlaintext => "sasl_plaintext",
37 SecurityProtocol::SaslSsl => "sasl_ssl",
38 }
39 }
40
41 #[must_use]
43 pub fn uses_ssl(&self) -> bool {
44 matches!(self, SecurityProtocol::Ssl | SecurityProtocol::SaslSsl)
45 }
46
47 #[must_use]
49 pub fn uses_sasl(&self) -> bool {
50 matches!(
51 self,
52 SecurityProtocol::SaslPlaintext | SecurityProtocol::SaslSsl
53 )
54 }
55}
56
57str_enum!(fromstr SecurityProtocol, lowercase, ConnectorError, "invalid security.protocol",
58 Plaintext => "plaintext";
59 Ssl => "ssl";
60 SaslPlaintext => "sasl_plaintext";
61 SaslSsl => "sasl_ssl"
62);
63
64impl std::fmt::Display for SecurityProtocol {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 write!(f, "{}", self.as_rdkafka_str())
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72pub enum SaslMechanism {
73 #[default]
75 Plain,
76 ScramSha256,
78 ScramSha512,
80 Gssapi,
82 Oauthbearer,
84}
85
86impl SaslMechanism {
87 #[must_use]
89 pub fn as_rdkafka_str(&self) -> &'static str {
90 match self {
91 SaslMechanism::Plain => "PLAIN",
92 SaslMechanism::ScramSha256 => "SCRAM-SHA-256",
93 SaslMechanism::ScramSha512 => "SCRAM-SHA-512",
94 SaslMechanism::Gssapi => "GSSAPI",
95 SaslMechanism::Oauthbearer => "OAUTHBEARER",
96 }
97 }
98
99 #[must_use]
101 pub fn requires_credentials(&self) -> bool {
102 matches!(
103 self,
104 SaslMechanism::Plain | SaslMechanism::ScramSha256 | SaslMechanism::ScramSha512
105 )
106 }
107}
108
109str_enum!(fromstr SaslMechanism, uppercase, ConnectorError, "invalid sasl.mechanism",
110 Plain => "PLAIN";
111 ScramSha256 => "SCRAM_SHA_256", "SCRAM_SHA256";
112 ScramSha512 => "SCRAM_SHA_512", "SCRAM_SHA512";
113 Gssapi => "GSSAPI", "KERBEROS";
114 Oauthbearer => "OAUTHBEARER", "OAUTH"
115);
116
117impl std::fmt::Display for SaslMechanism {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 write!(f, "{}", self.as_rdkafka_str())
120 }
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
127pub enum IsolationLevel {
128 ReadUncommitted,
130 #[default]
132 ReadCommitted,
133}
134
135impl IsolationLevel {
136 #[must_use]
138 pub fn as_rdkafka_str(&self) -> &'static str {
139 match self {
140 IsolationLevel::ReadUncommitted => "read_uncommitted",
141 IsolationLevel::ReadCommitted => "read_committed",
142 }
143 }
144}
145
146str_enum!(fromstr IsolationLevel, lowercase, ConnectorError, "invalid isolation.level",
147 ReadUncommitted => "read_uncommitted";
148 ReadCommitted => "read_committed"
149);
150
151impl std::fmt::Display for IsolationLevel {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 write!(f, "{}", self.as_rdkafka_str())
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Default)]
163pub enum StartupMode {
164 #[default]
166 GroupOffsets,
167 Earliest,
169 Latest,
171 SpecificOffsets(HashMap<i32, i64>),
173 Timestamp(i64),
176}
177
178impl std::str::FromStr for StartupMode {
179 type Err = ConnectorError;
180
181 fn from_str(s: &str) -> Result<Self, Self::Err> {
182 match s.to_lowercase().replace('-', "_").as_str() {
183 "group_offsets" | "group" => Ok(StartupMode::GroupOffsets),
184 "earliest" => Ok(StartupMode::Earliest),
185 "latest" => Ok(StartupMode::Latest),
186 "timestamp" => Err(ConnectorError::ConfigurationError(
187 "use 'startup.timestamp.ms' to start from a timestamp, \
188 not 'startup.mode = timestamp'"
189 .into(),
190 )),
191 other => Err(ConnectorError::ConfigurationError(format!(
192 "invalid startup.mode: '{other}' (expected group-offsets/earliest/latest)"
193 ))),
194 }
195 }
196}
197
198impl std::fmt::Display for StartupMode {
199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200 match self {
201 StartupMode::GroupOffsets => write!(f, "group-offsets"),
202 StartupMode::Earliest => write!(f, "earliest"),
203 StartupMode::Latest => write!(f, "latest"),
204 StartupMode::SpecificOffsets(offsets) => {
205 write!(f, "specific-offsets({} partitions)", offsets.len())
206 }
207 StartupMode::Timestamp(ts) => write!(f, "timestamp({ts})"),
208 }
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum TopicSubscription {
215 Topics(Vec<String>),
217 Pattern(String),
219}
220
221impl TopicSubscription {
222 #[must_use]
224 pub fn topics(&self) -> Option<&[String]> {
225 match self {
226 TopicSubscription::Topics(t) => Some(t),
227 TopicSubscription::Pattern(_) => None,
228 }
229 }
230
231 #[must_use]
233 pub fn pattern(&self) -> Option<&str> {
234 match self {
235 TopicSubscription::Topics(_) => None,
236 TopicSubscription::Pattern(p) => Some(p),
237 }
238 }
239
240 #[must_use]
242 pub fn is_pattern(&self) -> bool {
243 matches!(self, TopicSubscription::Pattern(_))
244 }
245}
246
247impl Default for TopicSubscription {
248 fn default() -> Self {
249 TopicSubscription::Topics(Vec::new())
250 }
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum OffsetReset {
256 Earliest,
258 Latest,
260 None,
262}
263
264impl OffsetReset {
265 #[must_use]
267 pub fn as_rdkafka_str(&self) -> &'static str {
268 match self {
269 OffsetReset::Earliest => "earliest",
270 OffsetReset::Latest => "latest",
271 OffsetReset::None => "error",
272 }
273 }
274}
275
276str_enum!(fromstr OffsetReset, lowercase_nodash, ConnectorError, "invalid auto.offset.reset",
277 Earliest => "earliest", "beginning";
278 Latest => "latest", "end";
279 None => "none", "error"
280);
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum AssignmentStrategy {
285 Range,
287 RoundRobin,
289 CooperativeSticky,
291}
292
293impl AssignmentStrategy {
294 #[must_use]
296 pub fn as_rdkafka_str(&self) -> &'static str {
297 match self {
298 AssignmentStrategy::Range => "range",
299 AssignmentStrategy::RoundRobin => "roundrobin",
300 AssignmentStrategy::CooperativeSticky => "cooperative-sticky",
301 }
302 }
303}
304
305str_enum!(fromstr AssignmentStrategy, lowercase_nodash, ConnectorError,
306 "invalid partition.assignment.strategy",
307 Range => "range";
308 RoundRobin => "roundrobin", "round-robin", "round_robin";
309 CooperativeSticky => "cooperative-sticky", "cooperative_sticky"
310);
311
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum CompatibilityLevel {
315 Backward,
317 BackwardTransitive,
319 Forward,
321 ForwardTransitive,
323 Full,
325 FullTransitive,
327 None,
329}
330
331impl CompatibilityLevel {
332 #[must_use]
334 pub fn as_str(&self) -> &'static str {
335 match self {
336 CompatibilityLevel::Backward => "BACKWARD",
337 CompatibilityLevel::BackwardTransitive => "BACKWARD_TRANSITIVE",
338 CompatibilityLevel::Forward => "FORWARD",
339 CompatibilityLevel::ForwardTransitive => "FORWARD_TRANSITIVE",
340 CompatibilityLevel::Full => "FULL",
341 CompatibilityLevel::FullTransitive => "FULL_TRANSITIVE",
342 CompatibilityLevel::None => "NONE",
343 }
344 }
345}
346
347str_enum!(fromstr CompatibilityLevel, uppercase, ConnectorError, "invalid schema.compatibility",
348 Backward => "BACKWARD";
349 BackwardTransitive => "BACKWARD_TRANSITIVE";
350 Forward => "FORWARD";
351 ForwardTransitive => "FORWARD_TRANSITIVE";
352 Full => "FULL";
353 FullTransitive => "FULL_TRANSITIVE";
354 None => "NONE"
355);
356
357impl std::fmt::Display for CompatibilityLevel {
358 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359 write!(f, "{}", self.as_str())
360 }
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
365pub enum SchemaEvolutionStrategy {
366 #[default]
368 Log,
369 Reject,
371 Ignore,
373}
374
375str_enum!(fromstr SchemaEvolutionStrategy, lowercase, ConnectorError,
376 "invalid schema.evolution.strategy",
377 Log => "log";
378 Reject => "reject";
379 Ignore => "ignore"
380);
381
382impl std::fmt::Display for SchemaEvolutionStrategy {
383 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384 match self {
385 SchemaEvolutionStrategy::Log => write!(f, "log"),
386 SchemaEvolutionStrategy::Reject => write!(f, "reject"),
387 SchemaEvolutionStrategy::Ignore => write!(f, "ignore"),
388 }
389 }
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
394pub enum SubjectNameStrategy {
395 #[default]
397 TopicName,
398 RecordName,
400 TopicRecordName,
402}
403
404str_enum!(fromstr SubjectNameStrategy, lowercase_nodash, ConnectorError,
405 "invalid schema.registry.subject.name.strategy",
406 TopicName => "topic-name", "topicname", "topicnamestrategy";
407 RecordName => "record-name", "recordname", "recordnamestrategy";
408 TopicRecordName => "topic-record-name", "topicrecordname", "topicrecordnamestrategy"
409);
410
411impl std::fmt::Display for SubjectNameStrategy {
412 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
413 match self {
414 SubjectNameStrategy::TopicName => write!(f, "topic-name"),
415 SubjectNameStrategy::RecordName => write!(f, "record-name"),
416 SubjectNameStrategy::TopicRecordName => write!(f, "topic-record-name"),
417 }
418 }
419}
420
421pub(crate) fn resolve_value_subject(
425 strategy: SubjectNameStrategy,
426 record_name: Option<&str>,
427 topic: &str,
428) -> String {
429 let name = || record_name.expect("from_config validates record.name");
430 match strategy {
431 SubjectNameStrategy::TopicName => format!("{topic}-value"),
432 SubjectNameStrategy::RecordName => format!("{}-value", name()),
433 SubjectNameStrategy::TopicRecordName => format!("{topic}-{}-value", name()),
434 }
435}
436
437impl From<CompatibilityLevel> for crate::schema::traits::CompatibilityMode {
440 fn from(level: CompatibilityLevel) -> Self {
441 use crate::schema::traits::CompatibilityMode;
442 match level {
443 CompatibilityLevel::Backward => CompatibilityMode::Backward,
444 CompatibilityLevel::BackwardTransitive => CompatibilityMode::BackwardTransitive,
445 CompatibilityLevel::Forward => CompatibilityMode::Forward,
446 CompatibilityLevel::ForwardTransitive => CompatibilityMode::ForwardTransitive,
447 CompatibilityLevel::Full => CompatibilityMode::Full,
448 CompatibilityLevel::FullTransitive => CompatibilityMode::FullTransitive,
449 CompatibilityLevel::None => CompatibilityMode::None,
450 }
451 }
452}
453
454#[derive(Clone)]
456pub struct SrAuth {
457 pub username: String,
459 pub password: String,
461}
462
463impl std::fmt::Debug for SrAuth {
464 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
465 f.debug_struct("SrAuth")
466 .field("username", &self.username)
467 .field("password", &"***")
468 .finish()
469 }
470}
471
472#[derive(Clone)]
477#[allow(clippy::struct_excessive_bools)] pub struct KafkaSourceConfig {
479 pub bootstrap_servers: String,
482 pub group_id: String,
484 pub subscription: TopicSubscription,
486
487 pub security_protocol: SecurityProtocol,
490 pub sasl_mechanism: Option<SaslMechanism>,
492 pub sasl_username: Option<String>,
494 pub sasl_password: Option<String>,
496 pub ssl_ca_location: Option<String>,
498 pub ssl_certificate_location: Option<String>,
500 pub ssl_key_location: Option<String>,
502 pub ssl_key_password: Option<String>,
504
505 pub format: Format,
508 pub schema_registry_url: Option<String>,
510 pub schema_registry_auth: Option<SrAuth>,
512 pub schema_compatibility: Option<CompatibilityLevel>,
514 pub schema_evolution_strategy: SchemaEvolutionStrategy,
516 pub schema_registry_ssl_ca_location: Option<String>,
518 pub schema_registry_ssl_certificate_location: Option<String>,
520 pub schema_registry_ssl_key_location: Option<String>,
522 pub schema_registry_subject_strategy: SubjectNameStrategy,
524 pub schema_registry_record_name: Option<String>,
526 pub schema_registry_discovery_timeout: Duration,
529 pub include_metadata: bool,
531 pub include_headers: bool,
533
534 pub startup_mode: StartupMode,
537 pub auto_offset_reset: OffsetReset,
539 pub isolation_level: IsolationLevel,
541 pub max_poll_records: usize,
543 pub partition_assignment_strategy: AssignmentStrategy,
545 pub fetch_min_bytes: Option<i32>,
547 pub fetch_max_bytes: Option<i32>,
549 pub fetch_max_wait_ms: Option<i32>,
551 pub max_partition_fetch_bytes: Option<i32>,
553
554 pub session_timeout: Duration,
558 pub heartbeat_interval: Duration,
561 pub max_poll_interval: Duration,
568 pub queued_max_messages_kbytes: u32,
571
572 pub broker_commit_on_checkpoint: bool,
582
583 pub reader_channel_capacity: usize,
587 pub backpressure_high_watermark: f64,
589 pub backpressure_low_watermark: f64,
591
592 pub max_deser_error_rate: f64,
600
601 pub kafka_properties: HashMap<String, String>,
604}
605
606impl std::fmt::Debug for KafkaSourceConfig {
607 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
608 f.debug_struct("KafkaSourceConfig")
609 .field("bootstrap_servers", &self.bootstrap_servers)
610 .field("group_id", &self.group_id)
611 .field("subscription", &self.subscription)
612 .field("format", &self.format)
613 .field("security_protocol", &self.security_protocol)
614 .field("sasl_mechanism", &self.sasl_mechanism)
615 .field("sasl_username", &self.sasl_username)
616 .field("sasl_password", &self.sasl_password.as_ref().map(|_| "***"))
617 .field(
618 "ssl_key_password",
619 &self.ssl_key_password.as_ref().map(|_| "***"),
620 )
621 .field("max_poll_records", &self.max_poll_records)
622 .finish_non_exhaustive()
623 }
624}
625
626impl Default for KafkaSourceConfig {
627 fn default() -> Self {
628 Self {
629 bootstrap_servers: String::new(),
630 group_id: String::new(),
631 subscription: TopicSubscription::default(),
632 security_protocol: SecurityProtocol::default(),
633 sasl_mechanism: None,
634 sasl_username: None,
635 sasl_password: None,
636 ssl_ca_location: None,
637 ssl_certificate_location: None,
638 ssl_key_location: None,
639 ssl_key_password: None,
640 format: Format::Json,
641 schema_registry_url: None,
642 schema_registry_auth: None,
643 schema_compatibility: None,
644 schema_evolution_strategy: SchemaEvolutionStrategy::default(),
645 schema_registry_ssl_ca_location: None,
646 schema_registry_ssl_certificate_location: None,
647 schema_registry_ssl_key_location: None,
648 schema_registry_subject_strategy: SubjectNameStrategy::default(),
649 schema_registry_record_name: None,
650 schema_registry_discovery_timeout: Duration::from_secs(10),
651 include_metadata: false,
652 include_headers: false,
653 startup_mode: StartupMode::default(),
654 auto_offset_reset: OffsetReset::Earliest,
655 isolation_level: IsolationLevel::default(),
656 max_poll_records: 1000,
657 partition_assignment_strategy: AssignmentStrategy::Range,
658 fetch_min_bytes: None,
659 fetch_max_bytes: None,
660 fetch_max_wait_ms: None,
661 max_partition_fetch_bytes: None,
662 session_timeout: Duration::from_secs(45),
663 heartbeat_interval: Duration::from_secs(10),
664 max_poll_interval: Duration::from_secs(600),
665 queued_max_messages_kbytes: 16384,
666 broker_commit_on_checkpoint: true,
667 reader_channel_capacity: 8192,
668 backpressure_high_watermark: 0.8,
669 backpressure_low_watermark: 0.25,
670 max_deser_error_rate: 0.5,
671 kafka_properties: HashMap::new(),
672 }
673 }
674}
675
676impl KafkaSourceConfig {
677 #[allow(deprecated, clippy::too_many_lines)]
683 pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
684 let bootstrap_servers = config.require("bootstrap.servers")?.to_string();
685 let group_id = config.require("group.id")?.to_string();
686
687 let subscription = if let Some(pattern) = config.get("topic.pattern") {
688 TopicSubscription::Pattern(pattern.to_string())
689 } else {
690 let topics_str = config.require("topic")?;
691 let topics: Vec<String> = topics_str
692 .split(',')
693 .map(|s| s.trim().to_string())
694 .collect();
695 TopicSubscription::Topics(topics.clone())
696 };
697
698 let security_protocol = match config.get("security.protocol") {
699 Some(s) => s.parse::<SecurityProtocol>()?,
700 None => SecurityProtocol::default(),
701 };
702
703 let sasl_mechanism = match config.get("sasl.mechanism") {
704 Some(s) => Some(s.parse::<SaslMechanism>()?),
705 None => None,
706 };
707
708 let sasl_username = config.get("sasl.username").map(String::from);
709 let sasl_password = config.get("sasl.password").map(String::from);
710 let ssl_ca_location = config.get("ssl.ca.location").map(String::from);
711 let ssl_certificate_location = config.get("ssl.certificate.location").map(String::from);
712 let ssl_key_location = config.get("ssl.key.location").map(String::from);
713 let ssl_key_password = config.get("ssl.key.password").map(String::from);
714
715 let format = match config.get("format") {
716 Some(f) => f
717 .parse::<Format>()
718 .map_err(|e| ConnectorError::ConfigurationError(e.to_string()))?,
719 None => Format::Json,
720 };
721
722 let schema_registry_url = config.get("schema.registry.url").map(String::from);
723
724 let schema_registry_auth = match (
725 config.get("schema.registry.username"),
726 config.get("schema.registry.password"),
727 ) {
728 (Some(u), Some(p)) => Some(SrAuth {
729 username: u.to_string(),
730 password: p.to_string(),
731 }),
732 (Some(_), None) | (None, Some(_)) => {
733 return Err(ConnectorError::ConfigurationError(
734 "schema.registry.username and schema.registry.password must both be set"
735 .to_string(),
736 ));
737 }
738 (None, None) => None,
739 };
740
741 let schema_compatibility = match config.get("schema.compatibility") {
742 Some(s) => Some(s.parse::<CompatibilityLevel>()?),
743 None => None,
744 };
745
746 let schema_evolution_strategy = match config.get("schema.evolution.strategy") {
747 Some(s) => s.parse::<SchemaEvolutionStrategy>()?,
748 None => SchemaEvolutionStrategy::default(),
749 };
750
751 let schema_registry_ssl_ca_location = config
752 .get("schema.registry.ssl.ca.location")
753 .map(String::from);
754 let schema_registry_ssl_certificate_location = config
755 .get("schema.registry.ssl.certificate.location")
756 .map(String::from);
757 let schema_registry_ssl_key_location = config
758 .get("schema.registry.ssl.key.location")
759 .map(String::from);
760
761 let schema_registry_subject_strategy =
762 match config.get("schema.registry.subject.name.strategy") {
763 Some(s) => s.parse::<SubjectNameStrategy>()?,
764 None => SubjectNameStrategy::default(),
765 };
766
767 let schema_registry_record_name =
768 config.get("schema.registry.record.name").map(String::from);
769
770 if matches!(
771 schema_registry_subject_strategy,
772 SubjectNameStrategy::RecordName | SubjectNameStrategy::TopicRecordName
773 ) && schema_registry_record_name.is_none()
774 {
775 return Err(ConnectorError::ConfigurationError(format!(
776 "schema.registry.subject.name.strategy={schema_registry_subject_strategy} \
777 requires schema.registry.record.name"
778 )));
779 }
780
781 let schema_registry_discovery_timeout = config
782 .get_parsed::<u64>("schema.registry.discovery.timeout.ms")?
783 .map_or(Duration::from_secs(10), Duration::from_millis);
784
785 let include_metadata = config
786 .get_parsed::<bool>("include.metadata")?
787 .unwrap_or(false);
788
789 let include_headers = config
790 .get_parsed::<bool>("include.headers")?
791 .unwrap_or(false);
792
793 let startup_mode = if let Some(offsets_str) = config.get("startup.specific.offsets") {
794 let offsets = parse_specific_offsets(offsets_str)?;
795 StartupMode::SpecificOffsets(offsets)
796 } else if let Some(ts_str) = config.get("startup.timestamp.ms") {
797 let ts: i64 = ts_str.parse().map_err(|_| {
798 ConnectorError::ConfigurationError(format!(
799 "invalid startup.timestamp.ms: '{ts_str}'"
800 ))
801 })?;
802 StartupMode::Timestamp(ts)
803 } else {
804 match config.get("startup.mode") {
805 Some(s) => s.parse::<StartupMode>()?,
806 None => StartupMode::default(),
807 }
808 };
809
810 let auto_offset_reset = match &startup_mode {
815 StartupMode::Earliest => OffsetReset::Earliest,
816 StartupMode::Latest => OffsetReset::Latest,
817 _ => match config.get("auto.offset.reset") {
818 Some(s) => s.parse::<OffsetReset>()?,
819 None => OffsetReset::Earliest,
820 },
821 };
822
823 let isolation_level = match config.get("isolation.level") {
824 Some(s) => s.parse::<IsolationLevel>()?,
825 None => IsolationLevel::default(),
826 };
827
828 let max_poll_records = config
829 .get_parsed::<usize>("max.poll.records")?
830 .unwrap_or(1000);
831
832 let partition_assignment_strategy = match config.get("partition.assignment.strategy") {
833 Some(s) => s.parse::<AssignmentStrategy>()?,
834 None => AssignmentStrategy::Range,
835 };
836
837 let fetch_min_bytes = config.get_parsed::<i32>("fetch.min.bytes")?;
838 let fetch_max_bytes = config.get_parsed::<i32>("fetch.max.bytes")?;
839 let fetch_max_wait_ms = config.get_parsed::<i32>("fetch.max.wait.ms")?;
840 let max_partition_fetch_bytes = config.get_parsed::<i32>("max.partition.fetch.bytes")?;
841
842 let session_timeout_ms = config
843 .get_parsed::<u64>("session.timeout.ms")?
844 .unwrap_or(45_000);
845 let heartbeat_interval_ms = config
846 .get_parsed::<u64>("heartbeat.interval.ms")?
847 .unwrap_or(10_000);
848 let queued_max_messages_kbytes = config
849 .get_parsed::<u32>("queued.max.messages.kbytes")?
850 .unwrap_or(16384);
851 let max_poll_interval_ms = config
852 .get_parsed::<u64>("max.poll.interval.ms")?
853 .unwrap_or(600_000);
854
855 if config
860 .properties()
861 .contains_key("broker.commit.interval.ms")
862 {
863 return Err(ConnectorError::ConfigurationError(
864 "broker.commit.interval.ms is no longer supported — broker offset \
865 commits now happen on checkpoint completion. Set \
866 broker.commit.on.checkpoint=false to disable."
867 .into(),
868 ));
869 }
870 let broker_commit_on_checkpoint = config
871 .get_parsed::<bool>("broker.commit.on.checkpoint")?
872 .unwrap_or(true);
873
874 let reader_channel_capacity = config
875 .get_parsed::<usize>("reader.channel.capacity")?
876 .unwrap_or(8192);
877
878 let backpressure_high_watermark = config
879 .get_parsed::<f64>("backpressure.high.watermark")?
880 .unwrap_or(0.8);
881
882 let backpressure_low_watermark = config
883 .get_parsed::<f64>("backpressure.low.watermark")?
884 .unwrap_or(0.25);
885
886 let max_deser_error_rate = config
887 .get_parsed::<f64>("max.deser.error.rate")?
888 .unwrap_or(0.5);
889
890 let kafka_properties = config.properties_with_prefix("kafka.");
891
892 let cfg = Self {
893 bootstrap_servers,
894 group_id,
895 subscription,
896 security_protocol,
897 sasl_mechanism,
898 sasl_username,
899 sasl_password,
900 ssl_ca_location,
901 ssl_certificate_location,
902 ssl_key_location,
903 ssl_key_password,
904 format,
905 schema_registry_url,
906 schema_registry_auth,
907 schema_compatibility,
908 schema_evolution_strategy,
909 schema_registry_ssl_ca_location,
910 schema_registry_ssl_certificate_location,
911 schema_registry_ssl_key_location,
912 schema_registry_subject_strategy,
913 schema_registry_record_name,
914 schema_registry_discovery_timeout,
915 include_metadata,
916 include_headers,
917 startup_mode,
918 auto_offset_reset,
919 isolation_level,
920 max_poll_records,
921 partition_assignment_strategy,
922 fetch_min_bytes,
923 fetch_max_bytes,
924 fetch_max_wait_ms,
925 max_partition_fetch_bytes,
926 session_timeout: Duration::from_millis(session_timeout_ms),
927 heartbeat_interval: Duration::from_millis(heartbeat_interval_ms),
928 max_poll_interval: Duration::from_millis(max_poll_interval_ms),
929 queued_max_messages_kbytes,
930 broker_commit_on_checkpoint,
931 reader_channel_capacity,
932 backpressure_high_watermark,
933 backpressure_low_watermark,
934 max_deser_error_rate,
935 kafka_properties,
936 };
937
938 cfg.validate()?;
939 Ok(cfg)
940 }
941
942 pub fn validate(&self) -> Result<(), ConnectorError> {
948 if self.bootstrap_servers.is_empty() {
949 return Err(ConnectorError::ConfigurationError(
950 "bootstrap.servers cannot be empty".into(),
951 ));
952 }
953 if self.group_id.is_empty() {
954 return Err(ConnectorError::ConfigurationError(
955 "group.id cannot be empty".into(),
956 ));
957 }
958
959 match &self.subscription {
961 TopicSubscription::Topics(t) if t.is_empty() => {
962 return Err(ConnectorError::ConfigurationError(
963 "at least one topic is required (or use topic.pattern)".into(),
964 ));
965 }
966 TopicSubscription::Pattern(p) if p.is_empty() => {
967 return Err(ConnectorError::ConfigurationError(
968 "topic.pattern cannot be empty".into(),
969 ));
970 }
971 _ => {}
972 }
973
974 if self.max_poll_records == 0 {
975 return Err(ConnectorError::ConfigurationError(
976 "max.poll.records must be > 0".into(),
977 ));
978 }
979 if self.reader_channel_capacity < self.max_poll_records {
980 return Err(ConnectorError::ConfigurationError(format!(
981 "reader.channel.capacity ({}) must be >= max.poll.records ({})",
982 self.reader_channel_capacity, self.max_poll_records
983 )));
984 }
985
986 if self.security_protocol.uses_sasl() && self.sasl_mechanism.is_none() {
987 return Err(ConnectorError::ConfigurationError(
988 "sasl.mechanism is required when security.protocol is sasl_plaintext or sasl_ssl"
989 .into(),
990 ));
991 }
992
993 if let Some(mechanism) = &self.sasl_mechanism {
994 if mechanism.requires_credentials()
995 && (self.sasl_username.is_none() || self.sasl_password.is_none())
996 {
997 return Err(ConnectorError::ConfigurationError(format!(
998 "sasl.username and sasl.password are required for {mechanism} mechanism"
999 )));
1000 }
1001 }
1002
1003 if self.security_protocol.uses_ssl() {
1004 if let Some(ref ca) = self.ssl_ca_location {
1005 if ca.is_empty() {
1006 return Err(ConnectorError::ConfigurationError(
1007 "ssl.ca.location cannot be empty when specified".into(),
1008 ));
1009 }
1010 }
1011 }
1012
1013 if self.max_poll_interval.as_secs() < 60 {
1014 return Err(ConnectorError::ConfigurationError(format!(
1015 "max.poll.interval.ms ({}) must be >= 60000 (60s)",
1016 self.max_poll_interval.as_millis()
1017 )));
1018 }
1019
1020 if self.heartbeat_interval.as_millis() * 3 >= self.session_timeout.as_millis() {
1022 return Err(ConnectorError::ConfigurationError(format!(
1023 "heartbeat.interval.ms ({}) * 3 must be < session.timeout.ms ({})",
1024 self.heartbeat_interval.as_millis(),
1025 self.session_timeout.as_millis()
1026 )));
1027 }
1028
1029 if self.backpressure_high_watermark <= self.backpressure_low_watermark {
1030 return Err(ConnectorError::ConfigurationError(
1031 "backpressure.high.watermark must be > backpressure.low.watermark".into(),
1032 ));
1033 }
1034 if !(0.0..=1.0).contains(&self.backpressure_high_watermark) {
1035 return Err(ConnectorError::ConfigurationError(
1036 "backpressure.high.watermark must be between 0.0 and 1.0".into(),
1037 ));
1038 }
1039 if !(0.0..=1.0).contains(&self.backpressure_low_watermark) {
1040 return Err(ConnectorError::ConfigurationError(
1041 "backpressure.low.watermark must be between 0.0 and 1.0".into(),
1042 ));
1043 }
1044
1045 if !(0.0..=1.0).contains(&self.max_deser_error_rate) {
1046 return Err(ConnectorError::ConfigurationError(
1047 "max.deser.error.rate must be between 0.0 and 1.0".into(),
1048 ));
1049 }
1050
1051 if self.format == Format::Avro && self.schema_registry_url.is_none() {
1052 return Err(ConnectorError::ConfigurationError(
1053 "schema.registry.url is required when format is 'avro'".into(),
1054 ));
1055 }
1056
1057 Ok(())
1058 }
1059
1060 #[must_use]
1062 pub fn to_rdkafka_config(&self) -> ClientConfig {
1063 let mut config = ClientConfig::new();
1064
1065 config.set("bootstrap.servers", &self.bootstrap_servers);
1066 config.set("group.id", &self.group_id);
1067 config.set("enable.auto.commit", "false");
1068 config.set("enable.auto.offset.store", "false");
1069 config.set("auto.offset.reset", self.auto_offset_reset.as_rdkafka_str());
1070 config.set(
1071 "partition.assignment.strategy",
1072 self.partition_assignment_strategy.as_rdkafka_str(),
1073 );
1074 config.set("security.protocol", self.security_protocol.as_rdkafka_str());
1075
1076 if let Some(ref mechanism) = self.sasl_mechanism {
1077 config.set("sasl.mechanism", mechanism.as_rdkafka_str());
1078 }
1079 if let Some(ref username) = self.sasl_username {
1080 config.set("sasl.username", username);
1081 }
1082 if let Some(ref password) = self.sasl_password {
1083 config.set("sasl.password", password);
1084 }
1085 if let Some(ref ca) = self.ssl_ca_location {
1086 config.set("ssl.ca.location", ca);
1087 }
1088 if let Some(ref cert) = self.ssl_certificate_location {
1089 config.set("ssl.certificate.location", cert);
1090 }
1091 if let Some(ref key) = self.ssl_key_location {
1092 config.set("ssl.key.location", key);
1093 }
1094 if let Some(ref key_pass) = self.ssl_key_password {
1095 config.set("ssl.key.password", key_pass);
1096 }
1097
1098 config.set("isolation.level", self.isolation_level.as_rdkafka_str());
1099 config.set(
1100 "session.timeout.ms",
1101 self.session_timeout.as_millis().to_string(),
1102 );
1103 config.set(
1104 "heartbeat.interval.ms",
1105 self.heartbeat_interval.as_millis().to_string(),
1106 );
1107 config.set(
1108 "queued.max.messages.kbytes",
1109 self.queued_max_messages_kbytes.to_string(),
1110 );
1111 config.set(
1112 "max.poll.interval.ms",
1113 self.max_poll_interval.as_millis().to_string(),
1114 );
1115
1116 if let Some(fetch_min) = self.fetch_min_bytes {
1117 config.set("fetch.min.bytes", fetch_min.to_string());
1118 }
1119
1120 if let Some(fetch_max) = self.fetch_max_bytes {
1121 config.set("fetch.max.bytes", fetch_max.to_string());
1122 }
1123
1124 if let Some(wait_ms) = self.fetch_max_wait_ms {
1125 config.set("fetch.wait.max.ms", wait_ms.to_string());
1126 }
1127
1128 if let Some(partition_max) = self.max_partition_fetch_bytes {
1129 config.set("max.partition.fetch.bytes", partition_max.to_string());
1130 }
1131
1132 for (key, value) in &self.kafka_properties {
1135 if is_blocked_passthrough_key(key) {
1136 tracing::warn!(
1137 key,
1138 "ignoring kafka.* pass-through property that overrides a security setting"
1139 );
1140 continue;
1141 }
1142 config.set(key, value);
1143 }
1144
1145 config
1146 }
1147}
1148
1149fn is_blocked_passthrough_key(key: &str) -> bool {
1151 key.starts_with("sasl.kerberos.")
1152 || matches!(
1153 key,
1154 "security.protocol"
1155 | "sasl.mechanism"
1156 | "sasl.username"
1157 | "sasl.password"
1158 | "sasl.oauthbearer.config"
1159 | "ssl.ca.location"
1160 | "ssl.certificate.location"
1161 | "ssl.key.location"
1162 | "ssl.key.password"
1163 | "ssl.endpoint.identification.algorithm"
1164 | "enable.auto.commit"
1165 | "enable.auto.offset.store"
1166 | "enable.idempotence"
1167 | "auto.offset.reset"
1168 | "session.timeout.ms"
1169 | "heartbeat.interval.ms"
1170 | "max.poll.interval.ms"
1171 | "queued.max.messages.kbytes"
1172 | "auto.commit.interval.ms"
1175 )
1176}
1177
1178fn parse_specific_offsets(s: &str) -> Result<HashMap<i32, i64>, ConnectorError> {
1182 let mut offsets = HashMap::new();
1183
1184 for pair in s.split(',') {
1185 let pair = pair.trim();
1186 if pair.is_empty() {
1187 continue;
1188 }
1189
1190 let parts: Vec<&str> = pair.split(':').collect();
1191 if parts.len() != 2 {
1192 return Err(ConnectorError::ConfigurationError(format!(
1193 "invalid offset format '{pair}' (expected 'partition:offset')"
1194 )));
1195 }
1196
1197 let partition: i32 = parts[0].trim().parse().map_err(|_| {
1198 ConnectorError::ConfigurationError(format!(
1199 "invalid partition number '{}' in '{pair}'",
1200 parts[0]
1201 ))
1202 })?;
1203
1204 let offset: i64 = parts[1].trim().parse().map_err(|_| {
1205 ConnectorError::ConfigurationError(format!("invalid offset '{}' in '{pair}'", parts[1]))
1206 })?;
1207
1208 offsets.insert(partition, offset);
1209 }
1210
1211 if offsets.is_empty() {
1212 return Err(ConnectorError::ConfigurationError(
1213 "startup.specific.offsets cannot be empty".into(),
1214 ));
1215 }
1216
1217 Ok(offsets)
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222 use super::*;
1223
1224 fn make_config(extra: &[(&str, &str)]) -> ConnectorConfig {
1225 let mut config = ConnectorConfig::new("kafka");
1226 config.set("bootstrap.servers", "localhost:9092");
1227 config.set("group.id", "test-group");
1228 config.set("topic", "events");
1229 for (k, v) in extra {
1230 config.set(*k, *v);
1231 }
1232 config
1233 }
1234
1235 #[test]
1236 #[allow(deprecated)]
1237 fn test_parse_required_fields() {
1238 let cfg = KafkaSourceConfig::from_config(&make_config(&[])).unwrap();
1239 assert_eq!(cfg.bootstrap_servers, "localhost:9092");
1240 assert_eq!(cfg.group_id, "test-group");
1241 assert_eq!(cfg.subscription.topics().unwrap(), &["events"]);
1242 assert!(matches!(
1243 cfg.subscription,
1244 TopicSubscription::Topics(ref t) if t == &["events"]
1245 ));
1246 }
1247
1248 #[test]
1249 fn test_parse_missing_required() {
1250 let config = ConnectorConfig::new("kafka");
1251 assert!(KafkaSourceConfig::from_config(&config).is_err());
1252 }
1253
1254 #[test]
1255 #[allow(deprecated)]
1256 fn test_parse_multi_topic() {
1257 let cfg = KafkaSourceConfig::from_config(&make_config(&[("topic", "a, b, c")])).unwrap();
1258 assert_eq!(cfg.subscription.topics().unwrap(), &["a", "b", "c"]);
1259 assert!(matches!(
1260 cfg.subscription,
1261 TopicSubscription::Topics(ref t) if t == &["a", "b", "c"]
1262 ));
1263 }
1264
1265 #[test]
1266 fn test_parse_topic_pattern() {
1267 let mut config = ConnectorConfig::new("kafka");
1268 config.set("bootstrap.servers", "localhost:9092");
1269 config.set("group.id", "test-group");
1270 config.set("topic.pattern", "events-.*");
1271
1272 let cfg = KafkaSourceConfig::from_config(&config).unwrap();
1273 assert!(matches!(
1274 cfg.subscription,
1275 TopicSubscription::Pattern(ref p) if p == "events-.*"
1276 ));
1277 assert!(cfg.subscription.is_pattern());
1278 assert_eq!(cfg.subscription.pattern(), Some("events-.*"));
1279 assert!(cfg.subscription.topics().is_none());
1280 }
1281
1282 #[test]
1283 fn test_parse_defaults() {
1284 let cfg = KafkaSourceConfig::from_config(&make_config(&[])).unwrap();
1285 assert_eq!(cfg.format, Format::Json);
1286 assert_eq!(cfg.auto_offset_reset, OffsetReset::Earliest);
1287 assert_eq!(cfg.isolation_level, IsolationLevel::ReadCommitted);
1288 assert_eq!(cfg.max_poll_records, 1000);
1289 assert_eq!(cfg.partition_assignment_strategy, AssignmentStrategy::Range);
1290 assert!(!cfg.include_metadata);
1291 assert!(!cfg.include_headers);
1292 assert!(cfg.schema_registry_url.is_none());
1293 assert_eq!(cfg.security_protocol, SecurityProtocol::Plaintext);
1294 assert!(cfg.sasl_mechanism.is_none());
1295 assert!(cfg.broker_commit_on_checkpoint);
1296 assert_eq!(cfg.reader_channel_capacity, 8192);
1297 assert_eq!(cfg.backpressure_high_watermark, 0.8);
1298 assert_eq!(cfg.backpressure_low_watermark, 0.25);
1299 }
1300
1301 #[test]
1302 fn test_parse_broker_commit_on_checkpoint_disabled() {
1303 let cfg = KafkaSourceConfig::from_config(&make_config(&[(
1304 "broker.commit.on.checkpoint",
1305 "false",
1306 )]))
1307 .unwrap();
1308 assert!(!cfg.broker_commit_on_checkpoint);
1309 }
1310
1311 #[test]
1312 fn test_parse_broker_commit_interval_rejected() {
1313 let err =
1314 KafkaSourceConfig::from_config(&make_config(&[("broker.commit.interval.ms", "5000")]))
1315 .unwrap_err();
1316 let msg = format!("{err}");
1317 assert!(
1318 msg.contains("broker.commit.interval.ms"),
1319 "expected hard-error mentioning the deprecated key, got: {msg}"
1320 );
1321 }
1322
1323 #[test]
1324 fn test_parse_optional_fields() {
1325 let cfg = KafkaSourceConfig::from_config(&make_config(&[
1326 ("format", "csv"),
1327 ("auto.offset.reset", "latest"),
1328 ("max.poll.records", "500"),
1329 ("include.metadata", "true"),
1330 ("include.headers", "true"),
1331 ("partition.assignment.strategy", "roundrobin"),
1332 ("isolation.level", "read_uncommitted"),
1333 ]))
1334 .unwrap();
1335
1336 assert_eq!(cfg.format, Format::Csv);
1337 assert_eq!(cfg.auto_offset_reset, OffsetReset::Latest);
1338 assert_eq!(cfg.isolation_level, IsolationLevel::ReadUncommitted);
1339 assert_eq!(cfg.max_poll_records, 500);
1340 assert!(cfg.include_metadata);
1341 assert!(cfg.include_headers);
1342 assert_eq!(
1343 cfg.partition_assignment_strategy,
1344 AssignmentStrategy::RoundRobin
1345 );
1346 }
1347
1348 #[test]
1349 fn test_parse_security_sasl_ssl() {
1350 let cfg = KafkaSourceConfig::from_config(&make_config(&[
1351 ("security.protocol", "sasl_ssl"),
1352 ("sasl.mechanism", "SCRAM-SHA-256"),
1353 ("sasl.username", "alice"),
1354 ("sasl.password", "secret"),
1355 ("ssl.ca.location", "/etc/ssl/ca.pem"),
1356 ]))
1357 .unwrap();
1358
1359 assert_eq!(cfg.security_protocol, SecurityProtocol::SaslSsl);
1360 assert_eq!(cfg.sasl_mechanism, Some(SaslMechanism::ScramSha256));
1361 assert_eq!(cfg.sasl_username, Some("alice".to_string()));
1362 assert_eq!(cfg.sasl_password, Some("secret".to_string()));
1363 assert_eq!(cfg.ssl_ca_location, Some("/etc/ssl/ca.pem".to_string()));
1364 }
1365
1366 #[test]
1367 fn test_parse_security_ssl_only() {
1368 let cfg = KafkaSourceConfig::from_config(&make_config(&[
1369 ("security.protocol", "ssl"),
1370 ("ssl.ca.location", "/etc/ssl/ca.pem"),
1371 ("ssl.certificate.location", "/etc/ssl/client.pem"),
1372 ("ssl.key.location", "/etc/ssl/client.key"),
1373 ("ssl.key.password", "keypass"),
1374 ]))
1375 .unwrap();
1376
1377 assert_eq!(cfg.security_protocol, SecurityProtocol::Ssl);
1378 assert!(cfg.security_protocol.uses_ssl());
1379 assert!(!cfg.security_protocol.uses_sasl());
1380 assert_eq!(cfg.ssl_ca_location, Some("/etc/ssl/ca.pem".to_string()));
1381 assert_eq!(
1382 cfg.ssl_certificate_location,
1383 Some("/etc/ssl/client.pem".to_string())
1384 );
1385 assert_eq!(
1386 cfg.ssl_key_location,
1387 Some("/etc/ssl/client.key".to_string())
1388 );
1389 assert_eq!(cfg.ssl_key_password, Some("keypass".to_string()));
1390 }
1391
1392 #[test]
1393 fn test_parse_fetch_tuning() {
1394 let cfg = KafkaSourceConfig::from_config(&make_config(&[
1395 ("fetch.min.bytes", "1024"),
1396 ("fetch.max.bytes", "52428800"),
1397 ("fetch.max.wait.ms", "500"),
1398 ("max.partition.fetch.bytes", "1048576"),
1399 ]))
1400 .unwrap();
1401
1402 assert_eq!(cfg.fetch_min_bytes, Some(1024));
1403 assert_eq!(cfg.fetch_max_bytes, Some(52_428_800));
1404 assert_eq!(cfg.fetch_max_wait_ms, Some(500));
1405 assert_eq!(cfg.max_partition_fetch_bytes, Some(1_048_576));
1406 }
1407
1408 #[test]
1409 fn test_parse_kafka_passthrough() {
1410 let cfg = KafkaSourceConfig::from_config(&make_config(&[
1411 ("kafka.session.timeout.ms", "30000"),
1412 ("kafka.max.poll.interval.ms", "300000"),
1413 ("kafka.fetch.message.max.bytes", "2097152"),
1414 ]))
1415 .unwrap();
1416
1417 assert_eq!(cfg.kafka_properties.len(), 3);
1418 assert_eq!(
1422 cfg.kafka_properties.get("session.timeout.ms"),
1423 Some(&"30000".to_string())
1424 );
1425 assert_eq!(
1426 cfg.kafka_properties.get("max.poll.interval.ms"),
1427 Some(&"300000".to_string())
1428 );
1429 }
1430
1431 #[test]
1432 fn test_parse_schema_registry() {
1433 let cfg = KafkaSourceConfig::from_config(&make_config(&[
1434 ("format", "avro"),
1435 ("schema.registry.url", "http://localhost:8081"),
1436 ("schema.registry.username", "user"),
1437 ("schema.registry.password", "pass"),
1438 ("schema.compatibility", "FULL_TRANSITIVE"),
1439 ("schema.registry.ssl.ca.location", "/etc/ssl/sr-ca.pem"),
1440 ]))
1441 .unwrap();
1442
1443 assert_eq!(cfg.format, Format::Avro);
1444 assert_eq!(
1445 cfg.schema_registry_url,
1446 Some("http://localhost:8081".to_string())
1447 );
1448 assert!(cfg.schema_registry_auth.is_some());
1449 let auth = cfg.schema_registry_auth.unwrap();
1450 assert_eq!(auth.username, "user");
1451 assert_eq!(auth.password, "pass");
1452 assert_eq!(
1453 cfg.schema_compatibility,
1454 Some(CompatibilityLevel::FullTransitive)
1455 );
1456 assert_eq!(
1457 cfg.schema_registry_ssl_ca_location,
1458 Some("/etc/ssl/sr-ca.pem".to_string())
1459 );
1460 }
1461
1462 #[test]
1463 fn test_parse_sr_auth_partial() {
1464 let config = make_config(&[
1465 ("schema.registry.url", "http://localhost:8081"),
1466 ("schema.registry.username", "user"),
1467 ]);
1469 assert!(KafkaSourceConfig::from_config(&config).is_err());
1470 }
1471
1472 #[test]
1473 fn test_validate_avro_without_sr() {
1474 let mut cfg = KafkaSourceConfig::default();
1475 cfg.bootstrap_servers = "localhost:9092".into();
1476 cfg.group_id = "g".into();
1477 cfg.subscription = TopicSubscription::Topics(vec!["t".into()]);
1478 cfg.format = Format::Avro;
1479 assert!(cfg.validate().is_err());
1481 }
1482
1483 #[test]
1484 fn test_validate_backpressure_watermarks() {
1485 let mut cfg = KafkaSourceConfig::default();
1486 cfg.bootstrap_servers = "localhost:9092".into();
1487 cfg.group_id = "g".into();
1488 cfg.subscription = TopicSubscription::Topics(vec!["t".into()]);
1489 cfg.backpressure_high_watermark = 0.3;
1490 cfg.backpressure_low_watermark = 0.5;
1491 assert!(cfg.validate().is_err());
1492 }
1493
1494 #[test]
1495 fn test_validate_sasl_without_mechanism() {
1496 let mut cfg = KafkaSourceConfig::default();
1497 cfg.bootstrap_servers = "localhost:9092".into();
1498 cfg.group_id = "g".into();
1499 cfg.subscription = TopicSubscription::Topics(vec!["t".into()]);
1500 cfg.security_protocol = SecurityProtocol::SaslPlaintext;
1501 assert!(cfg.validate().is_err());
1503 }
1504
1505 #[test]
1506 fn test_validate_sasl_plain_without_credentials() {
1507 let mut cfg = KafkaSourceConfig::default();
1508 cfg.bootstrap_servers = "localhost:9092".into();
1509 cfg.group_id = "g".into();
1510 cfg.subscription = TopicSubscription::Topics(vec!["t".into()]);
1511 cfg.security_protocol = SecurityProtocol::SaslPlaintext;
1512 cfg.sasl_mechanism = Some(SaslMechanism::Plain);
1513 assert!(cfg.validate().is_err());
1515 }
1516
1517 #[test]
1518 fn test_validate_empty_topic_pattern() {
1519 let mut cfg = KafkaSourceConfig::default();
1520 cfg.bootstrap_servers = "localhost:9092".into();
1521 cfg.group_id = "g".into();
1522 cfg.subscription = TopicSubscription::Pattern(String::new());
1523 assert!(cfg.validate().is_err());
1524 }
1525
1526 #[test]
1527 fn test_rdkafka_config() {
1528 let cfg = KafkaSourceConfig::from_config(&make_config(&[
1529 ("auto.offset.reset", "latest"),
1530 ("kafka.fetch.min.bytes", "1024"),
1531 ]))
1532 .unwrap();
1533
1534 let rdkafka = cfg.to_rdkafka_config();
1535 assert_eq!(rdkafka.get("bootstrap.servers"), Some("localhost:9092"));
1536 assert_eq!(rdkafka.get("group.id"), Some("test-group"));
1537 assert_eq!(rdkafka.get("enable.auto.commit"), Some("false"));
1538 assert_eq!(rdkafka.get("auto.offset.reset"), Some("latest"));
1539 assert_eq!(rdkafka.get("fetch.min.bytes"), Some("1024"));
1540 assert_eq!(rdkafka.get("security.protocol"), Some("plaintext"));
1541 assert_eq!(rdkafka.get("isolation.level"), Some("read_committed"));
1542 }
1543
1544 #[test]
1545 fn test_rdkafka_config_with_security() {
1546 let cfg = KafkaSourceConfig::from_config(&make_config(&[
1547 ("security.protocol", "sasl_ssl"),
1548 ("sasl.mechanism", "PLAIN"),
1549 ("sasl.username", "user"),
1550 ("sasl.password", "pass"),
1551 ("ssl.ca.location", "/ca.pem"),
1552 ]))
1553 .unwrap();
1554
1555 let rdkafka = cfg.to_rdkafka_config();
1556 assert_eq!(rdkafka.get("security.protocol"), Some("sasl_ssl"));
1557 assert_eq!(rdkafka.get("sasl.mechanism"), Some("PLAIN"));
1558 assert_eq!(rdkafka.get("sasl.username"), Some("user"));
1559 assert_eq!(rdkafka.get("sasl.password"), Some("pass"));
1560 assert_eq!(rdkafka.get("ssl.ca.location"), Some("/ca.pem"));
1561 }
1562
1563 #[test]
1564 fn test_rdkafka_config_with_fetch_tuning() {
1565 let cfg = KafkaSourceConfig::from_config(&make_config(&[
1566 ("fetch.min.bytes", "1024"),
1567 ("fetch.max.bytes", "1048576"),
1568 ("fetch.max.wait.ms", "500"),
1569 ("max.partition.fetch.bytes", "262144"),
1570 ("isolation.level", "read_uncommitted"),
1571 ]))
1572 .unwrap();
1573
1574 let rdkafka = cfg.to_rdkafka_config();
1575 assert_eq!(rdkafka.get("fetch.min.bytes"), Some("1024"));
1576 assert_eq!(rdkafka.get("fetch.max.bytes"), Some("1048576"));
1577 assert_eq!(rdkafka.get("fetch.wait.max.ms"), Some("500"));
1578 assert_eq!(rdkafka.get("max.partition.fetch.bytes"), Some("262144"));
1579 assert_eq!(rdkafka.get("isolation.level"), Some("read_uncommitted"));
1580 }
1581
1582 #[test]
1583 fn test_offset_reset_parsing() {
1584 assert_eq!(
1585 "earliest".parse::<OffsetReset>().unwrap(),
1586 OffsetReset::Earliest
1587 );
1588 assert_eq!(
1589 "latest".parse::<OffsetReset>().unwrap(),
1590 OffsetReset::Latest
1591 );
1592 assert_eq!("none".parse::<OffsetReset>().unwrap(), OffsetReset::None);
1593 assert!("invalid".parse::<OffsetReset>().is_err());
1594 }
1595
1596 #[test]
1597 fn test_compatibility_level_parsing() {
1598 assert_eq!(
1599 "BACKWARD".parse::<CompatibilityLevel>().unwrap(),
1600 CompatibilityLevel::Backward
1601 );
1602 assert_eq!(
1603 "full_transitive".parse::<CompatibilityLevel>().unwrap(),
1604 CompatibilityLevel::FullTransitive
1605 );
1606 assert_eq!(
1607 "NONE".parse::<CompatibilityLevel>().unwrap(),
1608 CompatibilityLevel::None
1609 );
1610 assert!("invalid".parse::<CompatibilityLevel>().is_err());
1611 }
1612
1613 #[test]
1614 fn test_security_protocol_parsing() {
1615 assert_eq!(
1616 "plaintext".parse::<SecurityProtocol>().unwrap(),
1617 SecurityProtocol::Plaintext
1618 );
1619 assert_eq!(
1620 "SSL".parse::<SecurityProtocol>().unwrap(),
1621 SecurityProtocol::Ssl
1622 );
1623 assert_eq!(
1624 "sasl_plaintext".parse::<SecurityProtocol>().unwrap(),
1625 SecurityProtocol::SaslPlaintext
1626 );
1627 assert_eq!(
1628 "SASL_SSL".parse::<SecurityProtocol>().unwrap(),
1629 SecurityProtocol::SaslSsl
1630 );
1631 assert_eq!(
1632 "sasl-ssl".parse::<SecurityProtocol>().unwrap(),
1633 SecurityProtocol::SaslSsl
1634 );
1635 assert!("invalid".parse::<SecurityProtocol>().is_err());
1636 }
1637
1638 #[test]
1639 fn test_sasl_mechanism_parsing() {
1640 assert_eq!(
1641 "PLAIN".parse::<SaslMechanism>().unwrap(),
1642 SaslMechanism::Plain
1643 );
1644 assert_eq!(
1645 "SCRAM-SHA-256".parse::<SaslMechanism>().unwrap(),
1646 SaslMechanism::ScramSha256
1647 );
1648 assert_eq!(
1649 "scram_sha_512".parse::<SaslMechanism>().unwrap(),
1650 SaslMechanism::ScramSha512
1651 );
1652 assert_eq!(
1653 "GSSAPI".parse::<SaslMechanism>().unwrap(),
1654 SaslMechanism::Gssapi
1655 );
1656 assert_eq!(
1657 "OAUTHBEARER".parse::<SaslMechanism>().unwrap(),
1658 SaslMechanism::Oauthbearer
1659 );
1660 assert!("invalid".parse::<SaslMechanism>().is_err());
1661 }
1662
1663 #[test]
1664 fn test_isolation_level_parsing() {
1665 assert_eq!(
1666 "read_uncommitted".parse::<IsolationLevel>().unwrap(),
1667 IsolationLevel::ReadUncommitted
1668 );
1669 assert_eq!(
1670 "read_committed".parse::<IsolationLevel>().unwrap(),
1671 IsolationLevel::ReadCommitted
1672 );
1673 assert_eq!(
1674 "read-committed".parse::<IsolationLevel>().unwrap(),
1675 IsolationLevel::ReadCommitted
1676 );
1677 assert!("invalid".parse::<IsolationLevel>().is_err());
1678 }
1679
1680 #[test]
1681 fn test_topic_subscription_accessors() {
1682 let topics = TopicSubscription::Topics(vec!["a".into(), "b".into()]);
1683 assert_eq!(
1684 topics.topics(),
1685 Some(&["a".to_string(), "b".to_string()][..])
1686 );
1687 assert!(topics.pattern().is_none());
1688 assert!(!topics.is_pattern());
1689
1690 let pattern = TopicSubscription::Pattern("events-.*".into());
1691 assert!(pattern.topics().is_none());
1692 assert_eq!(pattern.pattern(), Some("events-.*"));
1693 assert!(pattern.is_pattern());
1694 }
1695
1696 #[test]
1697 fn test_security_protocol_helpers() {
1698 assert!(!SecurityProtocol::Plaintext.uses_ssl());
1699 assert!(!SecurityProtocol::Plaintext.uses_sasl());
1700
1701 assert!(SecurityProtocol::Ssl.uses_ssl());
1702 assert!(!SecurityProtocol::Ssl.uses_sasl());
1703
1704 assert!(!SecurityProtocol::SaslPlaintext.uses_ssl());
1705 assert!(SecurityProtocol::SaslPlaintext.uses_sasl());
1706
1707 assert!(SecurityProtocol::SaslSsl.uses_ssl());
1708 assert!(SecurityProtocol::SaslSsl.uses_sasl());
1709 }
1710
1711 #[test]
1712 fn test_sasl_mechanism_helpers() {
1713 assert!(SaslMechanism::Plain.requires_credentials());
1714 assert!(SaslMechanism::ScramSha256.requires_credentials());
1715 assert!(SaslMechanism::ScramSha512.requires_credentials());
1716 assert!(!SaslMechanism::Gssapi.requires_credentials());
1717 assert!(!SaslMechanism::Oauthbearer.requires_credentials());
1718 }
1719
1720 #[test]
1721 fn test_enum_display() {
1722 assert_eq!(SecurityProtocol::SaslSsl.to_string(), "sasl_ssl");
1723 assert_eq!(SaslMechanism::ScramSha256.to_string(), "SCRAM-SHA-256");
1724 assert_eq!(IsolationLevel::ReadCommitted.to_string(), "read_committed");
1725 }
1726
1727 #[test]
1728 fn test_startup_mode_parsing() {
1729 assert_eq!(
1730 "group-offsets".parse::<StartupMode>().unwrap(),
1731 StartupMode::GroupOffsets
1732 );
1733 assert_eq!(
1734 "group_offsets".parse::<StartupMode>().unwrap(),
1735 StartupMode::GroupOffsets
1736 );
1737 assert_eq!(
1738 "earliest".parse::<StartupMode>().unwrap(),
1739 StartupMode::Earliest
1740 );
1741 assert_eq!(
1742 "latest".parse::<StartupMode>().unwrap(),
1743 StartupMode::Latest
1744 );
1745 assert!("invalid".parse::<StartupMode>().is_err());
1746 }
1747
1748 #[test]
1749 fn test_startup_mode_display() {
1750 assert_eq!(StartupMode::GroupOffsets.to_string(), "group-offsets");
1751 assert_eq!(StartupMode::Earliest.to_string(), "earliest");
1752 assert_eq!(StartupMode::Latest.to_string(), "latest");
1753
1754 let specific = StartupMode::SpecificOffsets(HashMap::from([(0, 100), (1, 200)]));
1755 assert!(specific.to_string().contains("2 partitions"));
1756
1757 let ts = StartupMode::Timestamp(1234567890000);
1758 assert!(ts.to_string().contains("1234567890000"));
1759 }
1760
1761 #[test]
1762 fn test_startup_mode_latest_overrides_offset_reset() {
1763 let cfg =
1764 KafkaSourceConfig::from_config(&make_config(&[("startup.mode", "latest")])).unwrap();
1765 assert_eq!(cfg.auto_offset_reset, OffsetReset::Latest);
1766 let rdkafka = cfg.to_rdkafka_config();
1767 assert_eq!(rdkafka.get("auto.offset.reset"), Some("latest"));
1768 }
1769
1770 #[test]
1771 fn test_startup_mode_earliest_overrides_offset_reset() {
1772 let cfg =
1773 KafkaSourceConfig::from_config(&make_config(&[("startup.mode", "earliest")])).unwrap();
1774 assert_eq!(cfg.auto_offset_reset, OffsetReset::Earliest);
1775 }
1776
1777 #[test]
1778 fn test_startup_mode_group_offsets_uses_explicit_offset_reset() {
1779 let cfg = KafkaSourceConfig::from_config(&make_config(&[("auto.offset.reset", "latest")]))
1782 .unwrap();
1783 assert_eq!(cfg.auto_offset_reset, OffsetReset::Latest);
1784 }
1785
1786 #[test]
1787 fn test_parse_specific_offsets() {
1788 let offsets = parse_specific_offsets("0:100,1:200,2:300").unwrap();
1789 assert_eq!(offsets.get(&0), Some(&100));
1790 assert_eq!(offsets.get(&1), Some(&200));
1791 assert_eq!(offsets.get(&2), Some(&300));
1792 }
1793
1794 #[test]
1795 fn test_parse_specific_offsets_with_spaces() {
1796 let offsets = parse_specific_offsets(" 0:100 , 1:200 ").unwrap();
1797 assert_eq!(offsets.get(&0), Some(&100));
1798 assert_eq!(offsets.get(&1), Some(&200));
1799 }
1800
1801 #[test]
1802 fn test_parse_specific_offsets_errors() {
1803 assert!(parse_specific_offsets("").is_err());
1804 assert!(parse_specific_offsets("0").is_err());
1805 assert!(parse_specific_offsets("0:abc").is_err());
1806 assert!(parse_specific_offsets("abc:100").is_err());
1807 assert!(parse_specific_offsets("0:100:extra").is_err());
1808 }
1809
1810 #[test]
1811 fn test_parse_startup_mode_from_config() {
1812 let cfg =
1813 KafkaSourceConfig::from_config(&make_config(&[("startup.mode", "earliest")])).unwrap();
1814 assert_eq!(cfg.startup_mode, StartupMode::Earliest);
1815
1816 let cfg =
1817 KafkaSourceConfig::from_config(&make_config(&[("startup.mode", "latest")])).unwrap();
1818 assert_eq!(cfg.startup_mode, StartupMode::Latest);
1819
1820 let cfg =
1821 KafkaSourceConfig::from_config(&make_config(&[("startup.mode", "group-offsets")]))
1822 .unwrap();
1823 assert_eq!(cfg.startup_mode, StartupMode::GroupOffsets);
1824 }
1825
1826 #[test]
1827 fn test_parse_startup_specific_offsets_from_config() {
1828 let cfg = KafkaSourceConfig::from_config(&make_config(&[(
1829 "startup.specific.offsets",
1830 "0:100,1:200",
1831 )]))
1832 .unwrap();
1833
1834 match cfg.startup_mode {
1835 StartupMode::SpecificOffsets(offsets) => {
1836 assert_eq!(offsets.get(&0), Some(&100));
1837 assert_eq!(offsets.get(&1), Some(&200));
1838 }
1839 _ => panic!("expected SpecificOffsets"),
1840 }
1841 }
1842
1843 #[test]
1844 fn test_parse_startup_timestamp_from_config() {
1845 let cfg =
1846 KafkaSourceConfig::from_config(&make_config(&[("startup.timestamp.ms", "1234567890")]))
1847 .unwrap();
1848
1849 assert_eq!(cfg.startup_mode, StartupMode::Timestamp(1234567890));
1850 }
1851
1852 #[test]
1853 fn test_parse_schema_registry_ssl_fields() {
1854 let cfg = KafkaSourceConfig::from_config(&make_config(&[
1855 ("schema.registry.url", "https://sr:8081"),
1856 ("schema.registry.ssl.ca.location", "/ca.pem"),
1857 ("schema.registry.ssl.certificate.location", "/cert.pem"),
1858 ("schema.registry.ssl.key.location", "/key.pem"),
1859 ]))
1860 .unwrap();
1861
1862 assert_eq!(
1863 cfg.schema_registry_ssl_ca_location,
1864 Some("/ca.pem".to_string())
1865 );
1866 assert_eq!(
1867 cfg.schema_registry_ssl_certificate_location,
1868 Some("/cert.pem".to_string())
1869 );
1870 assert_eq!(
1871 cfg.schema_registry_ssl_key_location,
1872 Some("/key.pem".to_string())
1873 );
1874 }
1875
1876 #[test]
1879 fn test_startup_mode_timestamp_error() {
1880 let config = make_config(&[("startup.mode", "timestamp")]);
1881 let err = KafkaSourceConfig::from_config(&config).unwrap_err();
1882 let msg = err.to_string();
1883 assert!(
1884 msg.contains("startup.timestamp.ms"),
1885 "error should mention startup.timestamp.ms, got: {msg}"
1886 );
1887 }
1888
1889 #[test]
1892 fn test_session_timeout_heartbeat_defaults() {
1893 let cfg = KafkaSourceConfig::from_config(&make_config(&[])).unwrap();
1894 assert_eq!(cfg.session_timeout, Duration::from_secs(45));
1895 assert_eq!(cfg.heartbeat_interval, Duration::from_secs(10));
1896 }
1897
1898 #[test]
1899 fn test_session_timeout_heartbeat_custom() {
1900 let cfg = KafkaSourceConfig::from_config(&make_config(&[
1901 ("session.timeout.ms", "60000"),
1902 ("heartbeat.interval.ms", "15000"),
1903 ]))
1904 .unwrap();
1905 assert_eq!(cfg.session_timeout, Duration::from_secs(60));
1906 assert_eq!(cfg.heartbeat_interval, Duration::from_secs(15));
1907 }
1908
1909 #[test]
1910 fn test_session_timeout_heartbeat_validation_fails() {
1911 let config = make_config(&[
1913 ("session.timeout.ms", "45000"),
1914 ("heartbeat.interval.ms", "20000"),
1915 ]);
1916 let err = KafkaSourceConfig::from_config(&config).unwrap_err();
1917 let msg = err.to_string();
1918 assert!(msg.contains("heartbeat.interval.ms"), "got: {msg}");
1919 }
1920
1921 #[test]
1922 fn test_session_timeout_heartbeat_validation_passes() {
1923 let cfg = KafkaSourceConfig::from_config(&make_config(&[
1925 ("session.timeout.ms", "45000"),
1926 ("heartbeat.interval.ms", "10000"),
1927 ]))
1928 .unwrap();
1929 assert_eq!(cfg.session_timeout, Duration::from_secs(45));
1930 assert_eq!(cfg.heartbeat_interval, Duration::from_secs(10));
1931 }
1932
1933 #[test]
1934 fn test_session_timeout_heartbeat_in_rdkafka_config() {
1935 let cfg = KafkaSourceConfig::from_config(&make_config(&[
1936 ("session.timeout.ms", "60000"),
1937 ("heartbeat.interval.ms", "15000"),
1938 ]))
1939 .unwrap();
1940 let rdkafka = cfg.to_rdkafka_config();
1941 assert_eq!(rdkafka.get("session.timeout.ms"), Some("60000"));
1942 assert_eq!(rdkafka.get("heartbeat.interval.ms"), Some("15000"));
1943 }
1944
1945 #[test]
1946 fn test_session_timeout_passthrough_blocked() {
1947 let cfg =
1948 KafkaSourceConfig::from_config(&make_config(&[("kafka.session.timeout.ms", "99999")]))
1949 .unwrap();
1950 let rdkafka = cfg.to_rdkafka_config();
1952 assert_eq!(rdkafka.get("session.timeout.ms"), Some("45000"));
1953 }
1954
1955 #[test]
1958 fn test_queued_max_messages_kbytes_default() {
1959 let cfg = KafkaSourceConfig::from_config(&make_config(&[])).unwrap();
1960 assert_eq!(cfg.queued_max_messages_kbytes, 16384);
1961 }
1962
1963 #[test]
1964 fn test_queued_max_messages_kbytes_custom() {
1965 let cfg = KafkaSourceConfig::from_config(&make_config(&[(
1966 "queued.max.messages.kbytes",
1967 "32768",
1968 )]))
1969 .unwrap();
1970 assert_eq!(cfg.queued_max_messages_kbytes, 32768);
1971 }
1972
1973 #[test]
1974 fn test_queued_max_messages_kbytes_in_rdkafka_config() {
1975 let cfg =
1976 KafkaSourceConfig::from_config(&make_config(&[("queued.max.messages.kbytes", "8192")]))
1977 .unwrap();
1978 let rdkafka = cfg.to_rdkafka_config();
1979 assert_eq!(rdkafka.get("queued.max.messages.kbytes"), Some("8192"));
1980 }
1981
1982 #[test]
1985 fn resolve_subject_topic_name() {
1986 assert_eq!(
1987 resolve_value_subject(SubjectNameStrategy::TopicName, None, "orders"),
1988 "orders-value"
1989 );
1990 }
1991
1992 #[test]
1993 fn resolve_subject_record_name() {
1994 assert_eq!(
1995 resolve_value_subject(
1996 SubjectNameStrategy::RecordName,
1997 Some("com.acme.Order"),
1998 "orders"
1999 ),
2000 "com.acme.Order-value"
2001 );
2002 }
2003
2004 #[test]
2005 fn resolve_subject_topic_record_name() {
2006 assert_eq!(
2007 resolve_value_subject(
2008 SubjectNameStrategy::TopicRecordName,
2009 Some("com.acme.Order"),
2010 "orders"
2011 ),
2012 "orders-com.acme.Order-value"
2013 );
2014 }
2015
2016 #[test]
2017 fn parse_subject_strategy_from_config() {
2018 let cfg = KafkaSourceConfig::from_config(&make_config(&[
2019 ("schema.registry.url", "http://sr:8081"),
2020 ("schema.registry.subject.name.strategy", "record-name"),
2021 ("schema.registry.record.name", "com.acme.Order"),
2022 ]))
2023 .unwrap();
2024 assert_eq!(
2025 cfg.schema_registry_subject_strategy,
2026 SubjectNameStrategy::RecordName
2027 );
2028 assert_eq!(
2029 cfg.schema_registry_record_name.as_deref(),
2030 Some("com.acme.Order")
2031 );
2032 }
2033
2034 #[test]
2035 fn parse_subject_strategy_rejects_missing_record_name() {
2036 let err = KafkaSourceConfig::from_config(&make_config(&[
2037 ("schema.registry.url", "http://sr:8081"),
2038 ("schema.registry.subject.name.strategy", "record-name"),
2039 ]))
2040 .unwrap_err();
2041 assert!(matches!(err, ConnectorError::ConfigurationError(_)));
2042 }
2043
2044 #[test]
2045 fn parse_discovery_timeout_default_ten_seconds() {
2046 let cfg =
2047 KafkaSourceConfig::from_config(&make_config(&[("schema.registry.url", "http://sr")]))
2048 .unwrap();
2049 assert_eq!(
2050 cfg.schema_registry_discovery_timeout,
2051 Duration::from_secs(10)
2052 );
2053 }
2054
2055 #[test]
2056 fn parse_discovery_timeout_override() {
2057 let cfg = KafkaSourceConfig::from_config(&make_config(&[
2058 ("schema.registry.url", "http://sr"),
2059 ("schema.registry.discovery.timeout.ms", "25000"),
2060 ]))
2061 .unwrap();
2062 assert_eq!(
2063 cfg.schema_registry_discovery_timeout,
2064 Duration::from_secs(25)
2065 );
2066 }
2067}