1use std::collections::HashMap;
5use std::time::Duration;
6
7use rdkafka::config::ClientConfig;
8
9use crate::error::ConnectorError;
10use crate::serde::Format;
11
12mod source_options;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum SecurityProtocol {
19 #[default]
21 Plaintext,
22 Ssl,
24 SaslPlaintext,
26 SaslSsl,
28}
29
30impl SecurityProtocol {
31 #[must_use]
33 pub fn as_rdkafka_str(&self) -> &'static str {
34 match self {
35 SecurityProtocol::Plaintext => "plaintext",
36 SecurityProtocol::Ssl => "ssl",
37 SecurityProtocol::SaslPlaintext => "sasl_plaintext",
38 SecurityProtocol::SaslSsl => "sasl_ssl",
39 }
40 }
41
42 #[must_use]
44 pub fn uses_ssl(&self) -> bool {
45 matches!(self, SecurityProtocol::Ssl | SecurityProtocol::SaslSsl)
46 }
47
48 #[must_use]
50 pub fn uses_sasl(&self) -> bool {
51 matches!(
52 self,
53 SecurityProtocol::SaslPlaintext | SecurityProtocol::SaslSsl
54 )
55 }
56}
57
58str_enum!(fromstr SecurityProtocol, lowercase, ConnectorError, "invalid security.protocol",
59 Plaintext => "plaintext";
60 Ssl => "ssl";
61 SaslPlaintext => "sasl_plaintext";
62 SaslSsl => "sasl_ssl"
63);
64
65impl std::fmt::Display for SecurityProtocol {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 write!(f, "{}", self.as_rdkafka_str())
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub enum SaslMechanism {
74 #[default]
76 Plain,
77 ScramSha256,
79 ScramSha512,
81 Gssapi,
83 Oauthbearer,
85}
86
87impl SaslMechanism {
88 #[must_use]
90 pub fn as_rdkafka_str(&self) -> &'static str {
91 match self {
92 SaslMechanism::Plain => "PLAIN",
93 SaslMechanism::ScramSha256 => "SCRAM-SHA-256",
94 SaslMechanism::ScramSha512 => "SCRAM-SHA-512",
95 SaslMechanism::Gssapi => "GSSAPI",
96 SaslMechanism::Oauthbearer => "OAUTHBEARER",
97 }
98 }
99
100 #[must_use]
102 pub fn requires_credentials(&self) -> bool {
103 matches!(
104 self,
105 SaslMechanism::Plain | SaslMechanism::ScramSha256 | SaslMechanism::ScramSha512
106 )
107 }
108}
109
110str_enum!(fromstr SaslMechanism, uppercase, ConnectorError, "invalid sasl.mechanism",
111 Plain => "PLAIN";
112 ScramSha256 => "SCRAM_SHA_256", "SCRAM_SHA256";
113 ScramSha512 => "SCRAM_SHA_512", "SCRAM_SHA512";
114 Gssapi => "GSSAPI", "KERBEROS";
115 Oauthbearer => "OAUTHBEARER", "OAUTH"
116);
117
118impl std::fmt::Display for SaslMechanism {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 write!(f, "{}", self.as_rdkafka_str())
121 }
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
128pub enum IsolationLevel {
129 ReadUncommitted,
131 #[default]
133 ReadCommitted,
134}
135
136impl IsolationLevel {
137 #[must_use]
139 pub fn as_rdkafka_str(&self) -> &'static str {
140 match self {
141 IsolationLevel::ReadUncommitted => "read_uncommitted",
142 IsolationLevel::ReadCommitted => "read_committed",
143 }
144 }
145}
146
147str_enum!(fromstr IsolationLevel, lowercase, ConnectorError, "invalid isolation.level",
148 ReadUncommitted => "read_uncommitted";
149 ReadCommitted => "read_committed"
150);
151
152impl std::fmt::Display for IsolationLevel {
153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 write!(f, "{}", self.as_rdkafka_str())
155 }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Default)]
164pub enum StartupMode {
165 #[default]
167 GroupOffsets,
168 Earliest,
170 Latest,
172 SpecificOffsets(HashMap<i32, i64>),
174 Timestamp(i64),
177}
178
179impl std::str::FromStr for StartupMode {
180 type Err = ConnectorError;
181
182 fn from_str(s: &str) -> Result<Self, Self::Err> {
183 match s.to_lowercase().replace('-', "_").as_str() {
184 "group_offsets" | "group" => Ok(StartupMode::GroupOffsets),
185 "earliest" => Ok(StartupMode::Earliest),
186 "latest" => Ok(StartupMode::Latest),
187 "timestamp" => Err(ConnectorError::ConfigurationError(
188 "use 'startup.timestamp.ms' to start from a timestamp, \
189 not 'startup.mode = timestamp'"
190 .into(),
191 )),
192 other => Err(ConnectorError::ConfigurationError(format!(
193 "invalid startup.mode: '{other}' (expected group-offsets/earliest/latest)"
194 ))),
195 }
196 }
197}
198
199impl std::fmt::Display for StartupMode {
200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 match self {
202 StartupMode::GroupOffsets => write!(f, "group-offsets"),
203 StartupMode::Earliest => write!(f, "earliest"),
204 StartupMode::Latest => write!(f, "latest"),
205 StartupMode::SpecificOffsets(offsets) => {
206 write!(f, "specific-offsets({} partitions)", offsets.len())
207 }
208 StartupMode::Timestamp(ts) => write!(f, "timestamp({ts})"),
209 }
210 }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
215pub enum TopicSubscription {
216 Topics(Vec<String>),
218 Pattern(String),
220}
221
222impl TopicSubscription {
223 #[must_use]
225 pub fn topics(&self) -> Option<&[String]> {
226 match self {
227 TopicSubscription::Topics(t) => Some(t),
228 TopicSubscription::Pattern(_) => None,
229 }
230 }
231
232 #[must_use]
234 pub fn pattern(&self) -> Option<&str> {
235 match self {
236 TopicSubscription::Topics(_) => None,
237 TopicSubscription::Pattern(p) => Some(p),
238 }
239 }
240
241 #[must_use]
243 pub fn is_pattern(&self) -> bool {
244 matches!(self, TopicSubscription::Pattern(_))
245 }
246}
247
248impl Default for TopicSubscription {
249 fn default() -> Self {
250 TopicSubscription::Topics(Vec::new())
251 }
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum OffsetReset {
257 Earliest,
259 Latest,
261 None,
263}
264
265impl OffsetReset {
266 #[must_use]
268 pub fn as_rdkafka_str(&self) -> &'static str {
269 match self {
270 OffsetReset::Earliest => "earliest",
271 OffsetReset::Latest => "latest",
272 OffsetReset::None => "error",
273 }
274 }
275}
276
277str_enum!(fromstr OffsetReset, lowercase_nodash, ConnectorError, "invalid auto.offset.reset",
278 Earliest => "earliest", "beginning";
279 Latest => "latest", "end";
280 None => "none", "error"
281);
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub enum AssignmentStrategy {
286 Range,
288 RoundRobin,
290 CooperativeSticky,
292}
293
294impl AssignmentStrategy {
295 #[must_use]
297 pub fn as_rdkafka_str(&self) -> &'static str {
298 match self {
299 AssignmentStrategy::Range => "range",
300 AssignmentStrategy::RoundRobin => "roundrobin",
301 AssignmentStrategy::CooperativeSticky => "cooperative-sticky",
302 }
303 }
304}
305
306str_enum!(fromstr AssignmentStrategy, lowercase_nodash, ConnectorError,
307 "invalid partition.assignment.strategy",
308 Range => "range";
309 RoundRobin => "roundrobin", "round-robin", "round_robin";
310 CooperativeSticky => "cooperative-sticky", "cooperative_sticky"
311);
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub enum CompatibilityLevel {
316 Backward,
318 BackwardTransitive,
320 Forward,
322 ForwardTransitive,
324 Full,
326 FullTransitive,
328 None,
330}
331
332impl CompatibilityLevel {
333 #[must_use]
335 pub fn as_str(&self) -> &'static str {
336 match self {
337 CompatibilityLevel::Backward => "BACKWARD",
338 CompatibilityLevel::BackwardTransitive => "BACKWARD_TRANSITIVE",
339 CompatibilityLevel::Forward => "FORWARD",
340 CompatibilityLevel::ForwardTransitive => "FORWARD_TRANSITIVE",
341 CompatibilityLevel::Full => "FULL",
342 CompatibilityLevel::FullTransitive => "FULL_TRANSITIVE",
343 CompatibilityLevel::None => "NONE",
344 }
345 }
346}
347
348str_enum!(fromstr CompatibilityLevel, uppercase, ConnectorError, "invalid schema.compatibility",
349 Backward => "BACKWARD";
350 BackwardTransitive => "BACKWARD_TRANSITIVE";
351 Forward => "FORWARD";
352 ForwardTransitive => "FORWARD_TRANSITIVE";
353 Full => "FULL";
354 FullTransitive => "FULL_TRANSITIVE";
355 None => "NONE"
356);
357
358impl std::fmt::Display for CompatibilityLevel {
359 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360 write!(f, "{}", self.as_str())
361 }
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
366pub enum SchemaEvolutionStrategy {
367 #[default]
369 Log,
370 Reject,
372 Ignore,
374}
375
376str_enum!(fromstr SchemaEvolutionStrategy, lowercase, ConnectorError,
377 "invalid schema.evolution.strategy",
378 Log => "log";
379 Reject => "reject";
380 Ignore => "ignore"
381);
382
383impl std::fmt::Display for SchemaEvolutionStrategy {
384 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
385 match self {
386 SchemaEvolutionStrategy::Log => write!(f, "log"),
387 SchemaEvolutionStrategy::Reject => write!(f, "reject"),
388 SchemaEvolutionStrategy::Ignore => write!(f, "ignore"),
389 }
390 }
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
395pub enum SubjectNameStrategy {
396 #[default]
398 TopicName,
399 RecordName,
401 TopicRecordName,
403}
404
405str_enum!(fromstr SubjectNameStrategy, lowercase_nodash, ConnectorError,
406 "invalid schema.registry.subject.name.strategy",
407 TopicName => "topic-name", "topicname", "topicnamestrategy";
408 RecordName => "record-name", "recordname", "recordnamestrategy";
409 TopicRecordName => "topic-record-name", "topicrecordname", "topicrecordnamestrategy"
410);
411
412impl std::fmt::Display for SubjectNameStrategy {
413 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414 match self {
415 SubjectNameStrategy::TopicName => write!(f, "topic-name"),
416 SubjectNameStrategy::RecordName => write!(f, "record-name"),
417 SubjectNameStrategy::TopicRecordName => write!(f, "topic-record-name"),
418 }
419 }
420}
421
422pub(crate) fn resolve_value_subject(
426 strategy: SubjectNameStrategy,
427 record_name: Option<&str>,
428 topic: &str,
429) -> String {
430 let name = || record_name.expect("from_config validates record.name");
431 match strategy {
432 SubjectNameStrategy::TopicName => format!("{topic}-value"),
433 SubjectNameStrategy::RecordName => format!("{}-value", name()),
434 SubjectNameStrategy::TopicRecordName => format!("{topic}-{}-value", name()),
435 }
436}
437
438impl From<CompatibilityLevel> for crate::schema::traits::CompatibilityMode {
441 fn from(level: CompatibilityLevel) -> Self {
442 use crate::schema::traits::CompatibilityMode;
443 match level {
444 CompatibilityLevel::Backward => CompatibilityMode::Backward,
445 CompatibilityLevel::BackwardTransitive => CompatibilityMode::BackwardTransitive,
446 CompatibilityLevel::Forward => CompatibilityMode::Forward,
447 CompatibilityLevel::ForwardTransitive => CompatibilityMode::ForwardTransitive,
448 CompatibilityLevel::Full => CompatibilityMode::Full,
449 CompatibilityLevel::FullTransitive => CompatibilityMode::FullTransitive,
450 CompatibilityLevel::None => CompatibilityMode::None,
451 }
452 }
453}
454
455#[derive(Clone)]
457pub struct SrAuth {
458 pub username: String,
460 pub password: String,
462}
463
464impl std::fmt::Debug for SrAuth {
465 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466 f.debug_struct("SrAuth")
467 .field("username", &self.username)
468 .field("password", &"***")
469 .finish()
470 }
471}
472
473#[derive(Clone)]
478#[allow(clippy::struct_excessive_bools)] pub struct KafkaSourceConfig {
480 pub bootstrap_servers: String,
483 pub group_id: String,
485 pub subscription: TopicSubscription,
487
488 pub security_protocol: SecurityProtocol,
491 pub sasl_mechanism: Option<SaslMechanism>,
493 pub sasl_username: Option<String>,
495 pub sasl_password: Option<String>,
497 pub ssl_ca_location: Option<String>,
499 pub ssl_certificate_location: Option<String>,
501 pub ssl_key_location: Option<String>,
503 pub ssl_key_password: Option<String>,
505
506 pub format: Format,
509 pub schema_registry_url: Option<String>,
511 pub schema_registry_auth: Option<SrAuth>,
513 pub schema_compatibility: Option<CompatibilityLevel>,
515 pub schema_evolution_strategy: SchemaEvolutionStrategy,
517 pub schema_registry_ssl_ca_location: Option<String>,
519 pub schema_registry_ssl_certificate_location: Option<String>,
521 pub schema_registry_ssl_key_location: Option<String>,
523 pub schema_registry_subject_strategy: SubjectNameStrategy,
525 pub schema_registry_record_name: Option<String>,
527 pub schema_registry_discovery_timeout: Duration,
530 pub include_metadata: bool,
532 pub include_headers: bool,
534
535 pub startup_mode: StartupMode,
538 pub auto_offset_reset: OffsetReset,
540 pub isolation_level: IsolationLevel,
542 pub max_poll_records: usize,
544 pub partition_assignment_strategy: AssignmentStrategy,
546 pub fetch_min_bytes: Option<i32>,
548 pub fetch_max_bytes: Option<i32>,
550 pub fetch_max_wait_ms: Option<i32>,
552 pub max_partition_fetch_bytes: Option<i32>,
554
555 pub session_timeout: Duration,
559 pub heartbeat_interval: Duration,
562 pub max_poll_interval: Duration,
569 pub queued_max_messages_kbytes: u32,
572
573 pub broker_commit_on_checkpoint: bool,
583
584 pub reader_channel_capacity: usize,
588 pub backpressure_high_watermark: f64,
590 pub backpressure_low_watermark: f64,
592
593 pub max_deser_error_rate: f64,
601
602 pub kafka_properties: HashMap<String, String>,
605}
606
607impl std::fmt::Debug for KafkaSourceConfig {
608 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
609 f.debug_struct("KafkaSourceConfig")
610 .field("bootstrap_servers", &self.bootstrap_servers)
611 .field("group_id", &self.group_id)
612 .field("subscription", &self.subscription)
613 .field("format", &self.format)
614 .field("security_protocol", &self.security_protocol)
615 .field("sasl_mechanism", &self.sasl_mechanism)
616 .field("sasl_username", &self.sasl_username)
617 .field("sasl_password", &self.sasl_password.as_ref().map(|_| "***"))
618 .field(
619 "ssl_key_password",
620 &self.ssl_key_password.as_ref().map(|_| "***"),
621 )
622 .field("max_poll_records", &self.max_poll_records)
623 .finish_non_exhaustive()
624 }
625}
626
627impl Default for KafkaSourceConfig {
628 fn default() -> Self {
629 Self {
630 bootstrap_servers: String::new(),
631 group_id: String::new(),
632 subscription: TopicSubscription::default(),
633 security_protocol: SecurityProtocol::default(),
634 sasl_mechanism: None,
635 sasl_username: None,
636 sasl_password: None,
637 ssl_ca_location: None,
638 ssl_certificate_location: None,
639 ssl_key_location: None,
640 ssl_key_password: None,
641 format: Format::Json,
642 schema_registry_url: None,
643 schema_registry_auth: None,
644 schema_compatibility: None,
645 schema_evolution_strategy: SchemaEvolutionStrategy::default(),
646 schema_registry_ssl_ca_location: None,
647 schema_registry_ssl_certificate_location: None,
648 schema_registry_ssl_key_location: None,
649 schema_registry_subject_strategy: SubjectNameStrategy::default(),
650 schema_registry_record_name: None,
651 schema_registry_discovery_timeout: Duration::from_secs(10),
652 include_metadata: false,
653 include_headers: false,
654 startup_mode: StartupMode::default(),
655 auto_offset_reset: OffsetReset::Earliest,
656 isolation_level: IsolationLevel::default(),
657 max_poll_records: 1000,
658 partition_assignment_strategy: AssignmentStrategy::Range,
659 fetch_min_bytes: None,
660 fetch_max_bytes: None,
661 fetch_max_wait_ms: None,
662 max_partition_fetch_bytes: None,
663 session_timeout: Duration::from_secs(45),
664 heartbeat_interval: Duration::from_secs(10),
665 max_poll_interval: Duration::from_secs(600),
666 queued_max_messages_kbytes: 16384,
667 broker_commit_on_checkpoint: true,
668 reader_channel_capacity: 8192,
669 backpressure_high_watermark: 0.8,
670 backpressure_low_watermark: 0.25,
671 max_deser_error_rate: 0.5,
672 kafka_properties: HashMap::new(),
673 }
674 }
675}
676
677impl KafkaSourceConfig {
678 pub fn validate(&self) -> Result<(), ConnectorError> {
684 if self.bootstrap_servers.is_empty() {
685 return Err(ConnectorError::ConfigurationError(
686 "bootstrap.servers cannot be empty".into(),
687 ));
688 }
689 if self.group_id.is_empty() {
690 return Err(ConnectorError::ConfigurationError(
691 "group.id cannot be empty".into(),
692 ));
693 }
694
695 match &self.subscription {
697 TopicSubscription::Topics(t) if t.is_empty() => {
698 return Err(ConnectorError::ConfigurationError(
699 "at least one topic is required (or use topic.pattern)".into(),
700 ));
701 }
702 TopicSubscription::Pattern(p) if p.is_empty() => {
703 return Err(ConnectorError::ConfigurationError(
704 "topic.pattern cannot be empty".into(),
705 ));
706 }
707 _ => {}
708 }
709
710 if self.max_poll_records == 0 {
711 return Err(ConnectorError::ConfigurationError(
712 "max.poll.records must be > 0".into(),
713 ));
714 }
715 if self.reader_channel_capacity < self.max_poll_records {
716 return Err(ConnectorError::ConfigurationError(format!(
717 "reader.channel.capacity ({}) must be >= max.poll.records ({})",
718 self.reader_channel_capacity, self.max_poll_records
719 )));
720 }
721
722 if self.security_protocol.uses_sasl() && self.sasl_mechanism.is_none() {
723 return Err(ConnectorError::ConfigurationError(
724 "sasl.mechanism is required when security.protocol is sasl_plaintext or sasl_ssl"
725 .into(),
726 ));
727 }
728
729 if let Some(mechanism) = &self.sasl_mechanism {
730 if mechanism.requires_credentials()
731 && (self.sasl_username.is_none() || self.sasl_password.is_none())
732 {
733 return Err(ConnectorError::ConfigurationError(format!(
734 "sasl.username and sasl.password are required for {mechanism} mechanism"
735 )));
736 }
737 }
738
739 if self.security_protocol.uses_ssl() {
740 if let Some(ref ca) = self.ssl_ca_location {
741 if ca.is_empty() {
742 return Err(ConnectorError::ConfigurationError(
743 "ssl.ca.location cannot be empty when specified".into(),
744 ));
745 }
746 }
747 }
748
749 if self.max_poll_interval.as_secs() < 60 {
750 return Err(ConnectorError::ConfigurationError(format!(
751 "max.poll.interval.ms ({}) must be >= 60000 (60s)",
752 self.max_poll_interval.as_millis()
753 )));
754 }
755
756 if self.heartbeat_interval.as_millis() * 3 >= self.session_timeout.as_millis() {
758 return Err(ConnectorError::ConfigurationError(format!(
759 "heartbeat.interval.ms ({}) * 3 must be < session.timeout.ms ({})",
760 self.heartbeat_interval.as_millis(),
761 self.session_timeout.as_millis()
762 )));
763 }
764
765 if self.backpressure_high_watermark <= self.backpressure_low_watermark {
766 return Err(ConnectorError::ConfigurationError(
767 "backpressure.high.watermark must be > backpressure.low.watermark".into(),
768 ));
769 }
770 if !(0.0..=1.0).contains(&self.backpressure_high_watermark) {
771 return Err(ConnectorError::ConfigurationError(
772 "backpressure.high.watermark must be between 0.0 and 1.0".into(),
773 ));
774 }
775 if !(0.0..=1.0).contains(&self.backpressure_low_watermark) {
776 return Err(ConnectorError::ConfigurationError(
777 "backpressure.low.watermark must be between 0.0 and 1.0".into(),
778 ));
779 }
780
781 if !(0.0..=1.0).contains(&self.max_deser_error_rate) {
782 return Err(ConnectorError::ConfigurationError(
783 "max.deser.error.rate must be between 0.0 and 1.0".into(),
784 ));
785 }
786
787 if self.format == Format::Avro && self.schema_registry_url.is_none() {
788 return Err(ConnectorError::ConfigurationError(
789 "schema.registry.url is required when format is 'avro'".into(),
790 ));
791 }
792
793 Ok(())
794 }
795
796 #[must_use]
798 pub fn to_rdkafka_config(&self) -> ClientConfig {
799 let mut config = ClientConfig::new();
800
801 config.set("bootstrap.servers", &self.bootstrap_servers);
802 config.set("group.id", &self.group_id);
803 config.set("enable.auto.commit", "false");
804 config.set("enable.auto.offset.store", "false");
805 config.set("auto.offset.reset", self.auto_offset_reset.as_rdkafka_str());
806 config.set(
807 "partition.assignment.strategy",
808 self.partition_assignment_strategy.as_rdkafka_str(),
809 );
810 config.set("security.protocol", self.security_protocol.as_rdkafka_str());
811
812 if let Some(ref mechanism) = self.sasl_mechanism {
813 config.set("sasl.mechanism", mechanism.as_rdkafka_str());
814 }
815 if let Some(ref username) = self.sasl_username {
816 config.set("sasl.username", username);
817 }
818 if let Some(ref password) = self.sasl_password {
819 config.set("sasl.password", password);
820 }
821 if let Some(ref ca) = self.ssl_ca_location {
822 config.set("ssl.ca.location", ca);
823 }
824 if let Some(ref cert) = self.ssl_certificate_location {
825 config.set("ssl.certificate.location", cert);
826 }
827 if let Some(ref key) = self.ssl_key_location {
828 config.set("ssl.key.location", key);
829 }
830 if let Some(ref key_pass) = self.ssl_key_password {
831 config.set("ssl.key.password", key_pass);
832 }
833
834 config.set("isolation.level", self.isolation_level.as_rdkafka_str());
835 config.set(
836 "session.timeout.ms",
837 self.session_timeout.as_millis().to_string(),
838 );
839 config.set(
840 "heartbeat.interval.ms",
841 self.heartbeat_interval.as_millis().to_string(),
842 );
843 config.set(
844 "queued.max.messages.kbytes",
845 self.queued_max_messages_kbytes.to_string(),
846 );
847 config.set(
848 "max.poll.interval.ms",
849 self.max_poll_interval.as_millis().to_string(),
850 );
851
852 if let Some(fetch_min) = self.fetch_min_bytes {
853 config.set("fetch.min.bytes", fetch_min.to_string());
854 }
855
856 if let Some(fetch_max) = self.fetch_max_bytes {
857 config.set("fetch.max.bytes", fetch_max.to_string());
858 }
859
860 if let Some(wait_ms) = self.fetch_max_wait_ms {
861 config.set("fetch.wait.max.ms", wait_ms.to_string());
862 }
863
864 if let Some(partition_max) = self.max_partition_fetch_bytes {
865 config.set("max.partition.fetch.bytes", partition_max.to_string());
866 }
867
868 for (key, value) in &self.kafka_properties {
871 if is_blocked_passthrough_key(key) {
872 tracing::warn!(
873 key,
874 "ignoring kafka.* pass-through property that overrides a security setting"
875 );
876 continue;
877 }
878 config.set(key, value);
879 }
880
881 config
882 }
883}
884
885fn is_blocked_passthrough_key(key: &str) -> bool {
887 key.starts_with("sasl.kerberos.")
888 || matches!(
889 key,
890 "security.protocol"
891 | "sasl.mechanism"
892 | "sasl.username"
893 | "sasl.password"
894 | "sasl.oauthbearer.config"
895 | "ssl.ca.location"
896 | "ssl.certificate.location"
897 | "ssl.key.location"
898 | "ssl.key.password"
899 | "ssl.endpoint.identification.algorithm"
900 | "enable.auto.commit"
901 | "enable.auto.offset.store"
902 | "enable.idempotence"
903 | "auto.offset.reset"
904 | "session.timeout.ms"
905 | "heartbeat.interval.ms"
906 | "max.poll.interval.ms"
907 | "queued.max.messages.kbytes"
908 | "auto.commit.interval.ms"
911 )
912}
913
914fn parse_specific_offsets(s: &str) -> Result<HashMap<i32, i64>, ConnectorError> {
918 let mut offsets = HashMap::new();
919
920 for pair in s.split(',') {
921 let pair = pair.trim();
922 if pair.is_empty() {
923 continue;
924 }
925
926 let parts: Vec<&str> = pair.split(':').collect();
927 if parts.len() != 2 {
928 return Err(ConnectorError::ConfigurationError(format!(
929 "invalid offset format '{pair}' (expected 'partition:offset')"
930 )));
931 }
932
933 let partition: i32 = parts[0].trim().parse().map_err(|_| {
934 ConnectorError::ConfigurationError(format!(
935 "invalid partition number '{}' in '{pair}'",
936 parts[0]
937 ))
938 })?;
939
940 let offset: i64 = parts[1].trim().parse().map_err(|_| {
941 ConnectorError::ConfigurationError(format!("invalid offset '{}' in '{pair}'", parts[1]))
942 })?;
943
944 offsets.insert(partition, offset);
945 }
946
947 if offsets.is_empty() {
948 return Err(ConnectorError::ConfigurationError(
949 "startup.specific.offsets cannot be empty".into(),
950 ));
951 }
952
953 Ok(offsets)
954}
955
956#[cfg(test)]
957mod tests;