1use std::collections::HashMap;
5use std::time::Duration;
6
7use rdkafka::config::ClientConfig;
8
9use crate::error::ConnectorError;
10use crate::serde::Format;
11
12pub use super::latency_profile::LatencyProfile;
13pub use super::source::{KafkaMetadataColumn, KafkaMetadataColumns};
14
15const DEFAULT_READER_CHANNEL_MAX_BYTES: usize = 64 * 1024 * 1024;
16const DEFAULT_MAX_POLL_BYTES: usize = 16 * 1024 * 1024;
17const DEFAULT_RECORD_MAX_BYTES: usize = 16 * 1024 * 1024;
18
19mod source_options;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum SecurityProtocol {
26 #[default]
28 Plaintext,
29 Ssl,
31 SaslPlaintext,
33 SaslSsl,
35}
36
37impl SecurityProtocol {
38 #[must_use]
40 pub fn as_rdkafka_str(&self) -> &'static str {
41 match self {
42 SecurityProtocol::Plaintext => "plaintext",
43 SecurityProtocol::Ssl => "ssl",
44 SecurityProtocol::SaslPlaintext => "sasl_plaintext",
45 SecurityProtocol::SaslSsl => "sasl_ssl",
46 }
47 }
48
49 #[must_use]
51 pub fn uses_ssl(&self) -> bool {
52 matches!(self, SecurityProtocol::Ssl | SecurityProtocol::SaslSsl)
53 }
54
55 #[must_use]
57 pub fn uses_sasl(&self) -> bool {
58 matches!(
59 self,
60 SecurityProtocol::SaslPlaintext | SecurityProtocol::SaslSsl
61 )
62 }
63}
64
65str_enum!(fromstr SecurityProtocol, lowercase, ConnectorError, "invalid security.protocol",
66 Plaintext => "plaintext";
67 Ssl => "ssl";
68 SaslPlaintext => "sasl_plaintext";
69 SaslSsl => "sasl_ssl"
70);
71
72impl std::fmt::Display for SecurityProtocol {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 write!(f, "{}", self.as_rdkafka_str())
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80pub enum SaslMechanism {
81 #[default]
83 Plain,
84 ScramSha256,
86 ScramSha512,
88 Gssapi,
90 Oauthbearer,
92}
93
94impl SaslMechanism {
95 #[must_use]
97 pub fn as_rdkafka_str(&self) -> &'static str {
98 match self {
99 SaslMechanism::Plain => "PLAIN",
100 SaslMechanism::ScramSha256 => "SCRAM-SHA-256",
101 SaslMechanism::ScramSha512 => "SCRAM-SHA-512",
102 SaslMechanism::Gssapi => "GSSAPI",
103 SaslMechanism::Oauthbearer => "OAUTHBEARER",
104 }
105 }
106
107 #[must_use]
109 pub fn requires_credentials(&self) -> bool {
110 matches!(
111 self,
112 SaslMechanism::Plain | SaslMechanism::ScramSha256 | SaslMechanism::ScramSha512
113 )
114 }
115}
116
117str_enum!(fromstr SaslMechanism, uppercase, ConnectorError, "invalid sasl.mechanism",
118 Plain => "PLAIN";
119 ScramSha256 => "SCRAM_SHA_256", "SCRAM_SHA256";
120 ScramSha512 => "SCRAM_SHA_512", "SCRAM_SHA512";
121 Gssapi => "GSSAPI", "KERBEROS";
122 Oauthbearer => "OAUTHBEARER", "OAUTH"
123);
124
125impl std::fmt::Display for SaslMechanism {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 write!(f, "{}", self.as_rdkafka_str())
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
135pub enum IsolationLevel {
136 ReadUncommitted,
138 #[default]
140 ReadCommitted,
141}
142
143impl IsolationLevel {
144 #[must_use]
146 pub fn as_rdkafka_str(&self) -> &'static str {
147 match self {
148 IsolationLevel::ReadUncommitted => "read_uncommitted",
149 IsolationLevel::ReadCommitted => "read_committed",
150 }
151 }
152}
153
154str_enum!(fromstr IsolationLevel, lowercase, ConnectorError, "invalid isolation.level",
155 ReadUncommitted => "read_uncommitted";
156 ReadCommitted => "read_committed"
157);
158
159impl std::fmt::Display for IsolationLevel {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 write!(f, "{}", self.as_rdkafka_str())
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Default)]
171pub enum StartupMode {
172 #[default]
174 GroupOffsets,
175 Earliest,
177 Latest,
179 SpecificOffsets(HashMap<i32, i64>),
181 Timestamp(i64),
184}
185
186impl std::str::FromStr for StartupMode {
187 type Err = ConnectorError;
188
189 fn from_str(s: &str) -> Result<Self, Self::Err> {
190 match s.to_lowercase().replace('-', "_").as_str() {
191 "group_offsets" | "group" => Ok(StartupMode::GroupOffsets),
192 "earliest" => Ok(StartupMode::Earliest),
193 "latest" => Ok(StartupMode::Latest),
194 "timestamp" => Err(ConnectorError::ConfigurationError(
195 "use 'startup.timestamp.ms' to start from a timestamp, \
196 not 'startup.mode = timestamp'"
197 .into(),
198 )),
199 other => Err(ConnectorError::ConfigurationError(format!(
200 "invalid startup.mode: '{other}' (expected group-offsets/earliest/latest)"
201 ))),
202 }
203 }
204}
205
206impl std::fmt::Display for StartupMode {
207 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208 match self {
209 StartupMode::GroupOffsets => write!(f, "group-offsets"),
210 StartupMode::Earliest => write!(f, "earliest"),
211 StartupMode::Latest => write!(f, "latest"),
212 StartupMode::SpecificOffsets(offsets) => {
213 write!(f, "specific-offsets({} partitions)", offsets.len())
214 }
215 StartupMode::Timestamp(ts) => write!(f, "timestamp({ts})"),
216 }
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq)]
222pub enum TopicSubscription {
223 Topics(Vec<String>),
225 Pattern(String),
227}
228
229impl TopicSubscription {
230 #[must_use]
232 pub fn topics(&self) -> Option<&[String]> {
233 match self {
234 TopicSubscription::Topics(t) => Some(t),
235 TopicSubscription::Pattern(_) => None,
236 }
237 }
238
239 #[must_use]
241 pub fn pattern(&self) -> Option<&str> {
242 match self {
243 TopicSubscription::Topics(_) => None,
244 TopicSubscription::Pattern(p) => Some(p),
245 }
246 }
247
248 #[must_use]
250 pub fn is_pattern(&self) -> bool {
251 matches!(self, TopicSubscription::Pattern(_))
252 }
253}
254
255impl Default for TopicSubscription {
256 fn default() -> Self {
257 TopicSubscription::Topics(Vec::new())
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum OffsetReset {
264 Earliest,
266 Latest,
268 None,
270}
271
272impl OffsetReset {
273 #[must_use]
275 pub fn as_rdkafka_str(&self) -> &'static str {
276 match self {
277 OffsetReset::Earliest => "earliest",
278 OffsetReset::Latest => "latest",
279 OffsetReset::None => "error",
280 }
281 }
282}
283
284str_enum!(fromstr OffsetReset, lowercase_nodash, ConnectorError, "invalid auto.offset.reset",
285 Earliest => "earliest", "beginning";
286 Latest => "latest", "end";
287 None => "none", "error"
288);
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum AssignmentStrategy {
293 Range,
295 RoundRobin,
297 CooperativeSticky,
299}
300
301impl AssignmentStrategy {
302 #[must_use]
304 pub fn as_rdkafka_str(&self) -> &'static str {
305 match self {
306 AssignmentStrategy::Range => "range",
307 AssignmentStrategy::RoundRobin => "roundrobin",
308 AssignmentStrategy::CooperativeSticky => "cooperative-sticky",
309 }
310 }
311}
312
313str_enum!(fromstr AssignmentStrategy, lowercase_nodash, ConnectorError,
314 "invalid partition.assignment.strategy",
315 Range => "range";
316 RoundRobin => "roundrobin", "round-robin", "round_robin";
317 CooperativeSticky => "cooperative-sticky", "cooperative_sticky"
318);
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
326pub enum GroupProtocol {
327 #[default]
329 Classic,
330 Consumer,
332}
333
334str_enum!(GroupProtocol, lowercase, ConnectorError, "invalid group.protocol",
335 Classic => "classic";
336 Consumer => "consumer"
337);
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub enum CompatibilityLevel {
342 Backward,
344 BackwardTransitive,
346 Forward,
348 ForwardTransitive,
350 Full,
352 FullTransitive,
354 None,
356}
357
358impl CompatibilityLevel {
359 #[must_use]
361 pub fn as_str(&self) -> &'static str {
362 match self {
363 CompatibilityLevel::Backward => "BACKWARD",
364 CompatibilityLevel::BackwardTransitive => "BACKWARD_TRANSITIVE",
365 CompatibilityLevel::Forward => "FORWARD",
366 CompatibilityLevel::ForwardTransitive => "FORWARD_TRANSITIVE",
367 CompatibilityLevel::Full => "FULL",
368 CompatibilityLevel::FullTransitive => "FULL_TRANSITIVE",
369 CompatibilityLevel::None => "NONE",
370 }
371 }
372}
373
374str_enum!(fromstr CompatibilityLevel, uppercase, ConnectorError, "invalid schema.compatibility",
375 Backward => "BACKWARD";
376 BackwardTransitive => "BACKWARD_TRANSITIVE";
377 Forward => "FORWARD";
378 ForwardTransitive => "FORWARD_TRANSITIVE";
379 Full => "FULL";
380 FullTransitive => "FULL_TRANSITIVE";
381 None => "NONE"
382);
383
384impl std::fmt::Display for CompatibilityLevel {
385 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386 write!(f, "{}", self.as_str())
387 }
388}
389
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
392pub enum SchemaEvolutionStrategy {
393 #[default]
395 Log,
396 Reject,
398 Ignore,
400}
401
402str_enum!(fromstr SchemaEvolutionStrategy, lowercase, ConnectorError,
403 "invalid schema.evolution.strategy",
404 Log => "log";
405 Reject => "reject";
406 Ignore => "ignore"
407);
408
409impl std::fmt::Display for SchemaEvolutionStrategy {
410 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411 match self {
412 SchemaEvolutionStrategy::Log => write!(f, "log"),
413 SchemaEvolutionStrategy::Reject => write!(f, "reject"),
414 SchemaEvolutionStrategy::Ignore => write!(f, "ignore"),
415 }
416 }
417}
418
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
421pub enum SubjectNameStrategy {
422 #[default]
424 TopicName,
425 RecordName,
427 TopicRecordName,
429}
430
431str_enum!(fromstr SubjectNameStrategy, lowercase_nodash, ConnectorError,
432 "invalid schema.registry.subject.name.strategy",
433 TopicName => "topic-name", "topicname", "topicnamestrategy";
434 RecordName => "record-name", "recordname", "recordnamestrategy";
435 TopicRecordName => "topic-record-name", "topicrecordname", "topicrecordnamestrategy"
436);
437
438impl std::fmt::Display for SubjectNameStrategy {
439 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
440 match self {
441 SubjectNameStrategy::TopicName => write!(f, "topic-name"),
442 SubjectNameStrategy::RecordName => write!(f, "record-name"),
443 SubjectNameStrategy::TopicRecordName => write!(f, "topic-record-name"),
444 }
445 }
446}
447
448pub(crate) fn resolve_value_subject(
452 strategy: SubjectNameStrategy,
453 record_name: Option<&str>,
454 topic: &str,
455) -> String {
456 let name = || record_name.expect("from_config validates record.name");
457 match strategy {
458 SubjectNameStrategy::TopicName => format!("{topic}-value"),
459 SubjectNameStrategy::RecordName => name().to_owned(),
460 SubjectNameStrategy::TopicRecordName => format!("{topic}-{}", name()),
461 }
462}
463
464impl From<CompatibilityLevel> for crate::schema::traits::CompatibilityMode {
467 fn from(level: CompatibilityLevel) -> Self {
468 use crate::schema::traits::CompatibilityMode;
469 match level {
470 CompatibilityLevel::Backward => CompatibilityMode::Backward,
471 CompatibilityLevel::BackwardTransitive => CompatibilityMode::BackwardTransitive,
472 CompatibilityLevel::Forward => CompatibilityMode::Forward,
473 CompatibilityLevel::ForwardTransitive => CompatibilityMode::ForwardTransitive,
474 CompatibilityLevel::Full => CompatibilityMode::Full,
475 CompatibilityLevel::FullTransitive => CompatibilityMode::FullTransitive,
476 CompatibilityLevel::None => CompatibilityMode::None,
477 }
478 }
479}
480
481#[derive(Clone)]
483pub struct SrAuth {
484 pub username: String,
486 pub password: String,
488}
489
490impl std::fmt::Debug for SrAuth {
491 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
492 f.debug_struct("SrAuth")
493 .field("username", &self.username)
494 .field("password", &"***")
495 .finish()
496 }
497}
498
499#[derive(Clone)]
504#[allow(clippy::struct_excessive_bools)] pub struct KafkaSourceConfig {
506 pub bootstrap_servers: String,
509 pub group_id: String,
511 pub subscription: TopicSubscription,
513
514 pub security_protocol: SecurityProtocol,
517 pub sasl_mechanism: Option<SaslMechanism>,
519 pub sasl_username: Option<String>,
521 pub sasl_password: Option<String>,
523 pub ssl_ca_location: Option<String>,
525 pub ssl_certificate_location: Option<String>,
527 pub ssl_key_location: Option<String>,
529 pub ssl_key_password: Option<String>,
531
532 pub format: Format,
535 pub schema_registry_url: Option<String>,
537 pub schema_registry_auth: Option<SrAuth>,
539 pub schema_compatibility: Option<CompatibilityLevel>,
541 pub schema_evolution_strategy: SchemaEvolutionStrategy,
543 pub schema_registry_ssl_ca_location: Option<String>,
545 pub schema_registry_ssl_certificate_location: Option<String>,
547 pub schema_registry_ssl_key_location: Option<String>,
549 pub schema_registry_subject_strategy: SubjectNameStrategy,
551 pub schema_registry_record_name: Option<String>,
553 pub schema_registry_discovery_timeout: Duration,
556 pub metadata: KafkaMetadataColumns,
558
559 pub startup_mode: StartupMode,
562 pub auto_offset_reset: OffsetReset,
564 pub isolation_level: IsolationLevel,
566 pub max_poll_records: usize,
568 pub partition_assignment_strategy: AssignmentStrategy,
570 pub group_protocol: GroupProtocol,
572 pub latency_profile: LatencyProfile,
574 pub fetch_min_bytes: Option<i32>,
576 pub fetch_max_bytes: Option<i32>,
578 pub fetch_max_wait_ms: Option<i32>,
580 pub max_partition_fetch_bytes: Option<i32>,
582
583 pub session_timeout: Duration,
587 pub heartbeat_interval: Duration,
590 pub max_poll_interval: Duration,
597 pub queued_max_messages_kbytes: u32,
600
601 pub broker_commit_on_checkpoint: bool,
611
612 pub reader_channel_capacity: usize,
616 pub reader_channel_max_bytes: usize,
619 pub max_poll_bytes: usize,
622 pub record_max_bytes: usize,
626 pub backpressure_high_watermark: f64,
628 pub backpressure_low_watermark: f64,
630
631 pub max_deser_error_rate: f64,
639
640 pub kafka_properties: HashMap<String, String>,
643}
644
645impl std::fmt::Debug for KafkaSourceConfig {
646 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
647 f.debug_struct("KafkaSourceConfig")
648 .field("bootstrap_servers", &self.bootstrap_servers)
649 .field("group_id", &self.group_id)
650 .field("subscription", &self.subscription)
651 .field("format", &self.format)
652 .field("security_protocol", &self.security_protocol)
653 .field("sasl_mechanism", &self.sasl_mechanism)
654 .field("sasl_username", &self.sasl_username)
655 .field("sasl_password", &self.sasl_password.as_ref().map(|_| "***"))
656 .field(
657 "ssl_key_password",
658 &self.ssl_key_password.as_ref().map(|_| "***"),
659 )
660 .field("max_poll_records", &self.max_poll_records)
661 .finish_non_exhaustive()
662 }
663}
664
665impl Default for KafkaSourceConfig {
666 fn default() -> Self {
667 Self {
668 bootstrap_servers: String::new(),
669 group_id: String::new(),
670 subscription: TopicSubscription::default(),
671 security_protocol: SecurityProtocol::default(),
672 sasl_mechanism: None,
673 sasl_username: None,
674 sasl_password: None,
675 ssl_ca_location: None,
676 ssl_certificate_location: None,
677 ssl_key_location: None,
678 ssl_key_password: None,
679 format: Format::Json,
680 schema_registry_url: None,
681 schema_registry_auth: None,
682 schema_compatibility: None,
683 schema_evolution_strategy: SchemaEvolutionStrategy::default(),
684 schema_registry_ssl_ca_location: None,
685 schema_registry_ssl_certificate_location: None,
686 schema_registry_ssl_key_location: None,
687 schema_registry_subject_strategy: SubjectNameStrategy::default(),
688 schema_registry_record_name: None,
689 schema_registry_discovery_timeout: Duration::from_secs(10),
690 metadata: KafkaMetadataColumns::default(),
691 startup_mode: StartupMode::default(),
692 auto_offset_reset: OffsetReset::Earliest,
693 isolation_level: IsolationLevel::default(),
694 max_poll_records: 1000,
695 partition_assignment_strategy: AssignmentStrategy::Range,
696 group_protocol: GroupProtocol::default(),
697 latency_profile: LatencyProfile::default(),
698 fetch_min_bytes: None,
699 fetch_max_bytes: None,
700 fetch_max_wait_ms: None,
701 max_partition_fetch_bytes: None,
702 session_timeout: Duration::from_secs(45),
703 heartbeat_interval: Duration::from_secs(10),
704 max_poll_interval: Duration::from_secs(600),
705 queued_max_messages_kbytes: 16384,
706 broker_commit_on_checkpoint: true,
707 reader_channel_capacity: 8192,
708 reader_channel_max_bytes: DEFAULT_READER_CHANNEL_MAX_BYTES,
709 max_poll_bytes: DEFAULT_MAX_POLL_BYTES,
710 record_max_bytes: DEFAULT_RECORD_MAX_BYTES,
711 backpressure_high_watermark: 0.8,
712 backpressure_low_watermark: 0.25,
713 max_deser_error_rate: 0.5,
714 kafka_properties: HashMap::new(),
715 }
716 }
717}
718
719impl KafkaSourceConfig {
720 pub fn validate(&self) -> Result<(), ConnectorError> {
726 if self.bootstrap_servers.is_empty() {
727 return Err(ConnectorError::ConfigurationError(
728 "bootstrap.servers cannot be empty".into(),
729 ));
730 }
731 if self.group_id.is_empty() {
732 return Err(ConnectorError::ConfigurationError(
733 "group.id cannot be empty".into(),
734 ));
735 }
736
737 match &self.subscription {
739 TopicSubscription::Topics(t) if t.is_empty() => {
740 return Err(ConnectorError::ConfigurationError(
741 "at least one topic is required (or use topic.pattern)".into(),
742 ));
743 }
744 TopicSubscription::Pattern(p) if p.is_empty() => {
745 return Err(ConnectorError::ConfigurationError(
746 "topic.pattern cannot be empty".into(),
747 ));
748 }
749 _ => {}
750 }
751
752 self.validate_memory_bounds()?;
753 self.validate_security()?;
754 if self.max_poll_interval.as_secs() < 60 {
755 return Err(ConnectorError::ConfigurationError(format!(
756 "max.poll.interval.ms ({}) must be >= 60000 (60s)",
757 self.max_poll_interval.as_millis()
758 )));
759 }
760
761 if self.group_protocol == GroupProtocol::Classic
763 && self.heartbeat_interval.as_millis() * 3 >= self.session_timeout.as_millis()
764 {
765 return Err(ConnectorError::ConfigurationError(format!(
766 "heartbeat.interval.ms ({}) * 3 must be < session.timeout.ms ({})",
767 self.heartbeat_interval.as_millis(),
768 self.session_timeout.as_millis()
769 )));
770 }
771
772 self.validate_backpressure()?;
773 if !(0.0..=1.0).contains(&self.max_deser_error_rate) {
774 return Err(ConnectorError::ConfigurationError(
775 "max.deser.error.rate must be between 0.0 and 1.0".into(),
776 ));
777 }
778
779 if self.format == Format::Avro && self.schema_registry_url.is_none() {
780 return Err(ConnectorError::ConfigurationError(
781 "schema.registry.url is required when format is 'avro'".into(),
782 ));
783 }
784 #[cfg(not(feature = "fpml"))]
785 if self.format == Format::Fpml {
786 return Err(ConnectorError::ConfigurationError(
787 "format 'fpml' requires a build with the fpml feature".into(),
788 ));
789 }
790
791 super::librdkafka::require_supported_client(
792 self.security_protocol,
793 self.sasl_mechanism,
794 None,
795 )?;
796 super::librdkafka::validate_passthrough(
797 &self.owned_rdkafka_config(),
798 &self.kafka_properties,
799 )
800 }
801
802 fn validate_memory_bounds(&self) -> Result<(), ConnectorError> {
804 if self.max_poll_records == 0 {
805 return Err(ConnectorError::ConfigurationError(
806 "max.poll.records must be > 0".into(),
807 ));
808 }
809 if self.reader_channel_capacity < self.max_poll_records {
810 return Err(ConnectorError::ConfigurationError(format!(
811 "reader.channel.capacity ({}) must be >= max.poll.records ({})",
812 self.reader_channel_capacity, self.max_poll_records
813 )));
814 }
815 if self.max_poll_bytes == 0 || self.record_max_bytes == 0 {
816 return Err(ConnectorError::ConfigurationError(
817 "max.poll.bytes and record.max.bytes must be > 0".into(),
818 ));
819 }
820 if self.record_max_bytes > self.reader_channel_max_bytes
821 || u32::try_from(self.reader_channel_max_bytes).is_err()
822 {
823 return Err(ConnectorError::ConfigurationError(format!(
824 "record.max.bytes ({}) must be <= reader.channel.max.bytes ({}), which must be \
825 <= {}",
826 self.record_max_bytes,
827 self.reader_channel_max_bytes,
828 u32::MAX
829 )));
830 }
831 Ok(())
832 }
833
834 fn validate_security(&self) -> Result<(), ConnectorError> {
836 if self.security_protocol.uses_sasl() && self.sasl_mechanism.is_none() {
837 return Err(ConnectorError::ConfigurationError(
838 "sasl.mechanism is required when security.protocol is sasl_plaintext or sasl_ssl"
839 .into(),
840 ));
841 }
842
843 if let Some(mechanism) = &self.sasl_mechanism {
844 if mechanism.requires_credentials()
845 && (self.sasl_username.is_none() || self.sasl_password.is_none())
846 {
847 return Err(ConnectorError::ConfigurationError(format!(
848 "sasl.username and sasl.password are required for {mechanism} mechanism"
849 )));
850 }
851 }
852
853 if self.security_protocol.uses_ssl() {
854 if let Some(ref ca) = self.ssl_ca_location {
855 if ca.is_empty() {
856 return Err(ConnectorError::ConfigurationError(
857 "ssl.ca.location cannot be empty when specified".into(),
858 ));
859 }
860 }
861 }
862 Ok(())
863 }
864
865 fn validate_backpressure(&self) -> Result<(), ConnectorError> {
866 if self.backpressure_high_watermark <= self.backpressure_low_watermark {
867 return Err(ConnectorError::ConfigurationError(
868 "backpressure.high.watermark must be > backpressure.low.watermark".into(),
869 ));
870 }
871 if !(0.0..=1.0).contains(&self.backpressure_high_watermark) {
872 return Err(ConnectorError::ConfigurationError(
873 "backpressure.high.watermark must be between 0.0 and 1.0".into(),
874 ));
875 }
876 if !(0.0..=1.0).contains(&self.backpressure_low_watermark) {
877 return Err(ConnectorError::ConfigurationError(
878 "backpressure.low.watermark must be between 0.0 and 1.0".into(),
879 ));
880 }
881 Ok(())
882 }
883
884 #[must_use]
887 pub fn to_rdkafka_config(&self) -> ClientConfig {
888 let mut config = self.owned_rdkafka_config();
889 super::librdkafka::apply_passthrough(&mut config, &self.kafka_properties);
890 config
891 }
892
893 fn owned_rdkafka_config(&self) -> ClientConfig {
894 let mut config = ClientConfig::new();
895
896 config.set("bootstrap.servers", &self.bootstrap_servers);
897 config.set("group.id", &self.group_id);
898 config.set("enable.auto.commit", "false");
899 config.set("enable.auto.offset.store", "false");
900 config.set("auto.offset.reset", self.auto_offset_reset.as_rdkafka_str());
901 config.set("group.protocol", self.group_protocol.to_string());
902 if self.group_protocol == GroupProtocol::Classic {
903 config.set(
904 "partition.assignment.strategy",
905 self.partition_assignment_strategy.as_rdkafka_str(),
906 );
907 config.set(
908 "session.timeout.ms",
909 self.session_timeout.as_millis().to_string(),
910 );
911 config.set(
912 "heartbeat.interval.ms",
913 self.heartbeat_interval.as_millis().to_string(),
914 );
915 }
916 config.set("security.protocol", self.security_protocol.as_rdkafka_str());
917
918 if let Some(ref mechanism) = self.sasl_mechanism {
919 config.set("sasl.mechanism", mechanism.as_rdkafka_str());
920 }
921 if let Some(ref username) = self.sasl_username {
922 config.set("sasl.username", username);
923 }
924 if let Some(ref password) = self.sasl_password {
925 config.set("sasl.password", password);
926 }
927 if let Some(ref ca) = self.ssl_ca_location {
928 config.set("ssl.ca.location", ca);
929 }
930 if let Some(ref cert) = self.ssl_certificate_location {
931 config.set("ssl.certificate.location", cert);
932 }
933 if let Some(ref key) = self.ssl_key_location {
934 config.set("ssl.key.location", key);
935 }
936 if let Some(ref key_pass) = self.ssl_key_password {
937 config.set("ssl.key.password", key_pass);
938 }
939
940 config.set("isolation.level", self.isolation_level.as_rdkafka_str());
941 config.set(
942 "queued.max.messages.kbytes",
943 self.queued_max_messages_kbytes.to_string(),
944 );
945 config.set(
946 "max.poll.interval.ms",
947 self.max_poll_interval.as_millis().to_string(),
948 );
949
950 if let Some(fetch_min) = self.fetch_min_bytes {
951 config.set("fetch.min.bytes", fetch_min.to_string());
952 }
953
954 if let Some(fetch_max) = self.fetch_max_bytes {
955 config.set("fetch.max.bytes", fetch_max.to_string());
956 }
957
958 if let Some(wait_ms) = self.fetch_max_wait_ms {
959 config.set("fetch.wait.max.ms", wait_ms.to_string());
960 }
961
962 if let Some(partition_max) = self.max_partition_fetch_bytes {
963 config.set("max.partition.fetch.bytes", partition_max.to_string());
964 }
965
966 config
967 }
968}
969
970fn parse_specific_offsets(s: &str) -> Result<HashMap<i32, i64>, ConnectorError> {
974 let mut offsets = HashMap::new();
975
976 for pair in s.split(',') {
977 let pair = pair.trim();
978 if pair.is_empty() {
979 continue;
980 }
981
982 let parts: Vec<&str> = pair.split(':').collect();
983 if parts.len() != 2 {
984 return Err(ConnectorError::ConfigurationError(format!(
985 "invalid offset format '{pair}' (expected 'partition:offset')"
986 )));
987 }
988
989 let partition: i32 = parts[0].trim().parse().map_err(|_| {
990 ConnectorError::ConfigurationError(format!(
991 "invalid partition number '{}' in '{pair}'",
992 parts[0]
993 ))
994 })?;
995
996 let offset: i64 = parts[1].trim().parse().map_err(|_| {
997 ConnectorError::ConfigurationError(format!("invalid offset '{}' in '{pair}'", parts[1]))
998 })?;
999
1000 offsets.insert(partition, offset);
1001 }
1002
1003 if offsets.is_empty() {
1004 return Err(ConnectorError::ConfigurationError(
1005 "startup.specific.offsets cannot be empty".into(),
1006 ));
1007 }
1008
1009 Ok(offsets)
1010}
1011
1012#[cfg(test)]
1013mod tests;