Skip to main content

laminar_connectors/kafka/
mod.rs

1//! Kafka source and sink connectors.
2
3// Source modules
4pub mod avro;
5pub mod config;
6mod metadata_error;
7pub mod metrics;
8mod offsets;
9pub mod rebalance;
10pub mod source;
11mod vnode_routing;
12
13// Sink modules
14pub mod avro_serializer;
15pub mod partitioner;
16pub mod sink;
17pub mod sink_config;
18pub mod sink_metrics;
19
20// Shared modules
21pub mod schema_registry;
22
23// Source re-exports
24pub use avro::AvroDeserializer;
25pub use config::{
26    AssignmentStrategy, CompatibilityLevel, IsolationLevel, KafkaSourceConfig, OffsetReset,
27    SaslMechanism, SchemaEvolutionStrategy, SecurityProtocol, SrAuth, StartupMode,
28    TopicSubscription,
29};
30pub use metrics::KafkaSourceMetrics;
31pub use source::KafkaSource;
32
33// Sink re-exports
34pub use avro_serializer::AvroSerializer;
35pub use partitioner::{
36    KafkaPartitioner, KeyHashPartitioner, RoundRobinPartitioner, StickyPartitioner,
37};
38pub use sink::KafkaSink;
39pub use sink_config::{CompressionType, KafkaSinkConfig, PartitionStrategy};
40pub use sink_metrics::KafkaSinkMetrics;
41
42/// Test-only access to Kafka-specific routing probes.
43#[cfg(feature = "testing")]
44pub mod testing {
45    use crate::error::ConnectorError;
46
47    /// Map a complete Kafka topic inventory to engine vnodes.
48    ///
49    /// # Errors
50    /// Returns a configuration error when the Kafka route identity, partition
51    /// inventory, or vnode count is invalid.
52    pub fn partition_vnodes(
53        source_identity: &str,
54        topic: &str,
55        total_partitions: i32,
56        vnode_count: u32,
57    ) -> Result<Vec<u32>, ConnectorError> {
58        super::vnode_routing::partition_vnodes(
59            source_identity,
60            topic,
61            total_partitions,
62            vnode_count,
63        )
64    }
65}
66
67// Shared re-exports
68pub use schema_registry::{CachedSchema, CompatibilityResult, SchemaRegistryClient, SchemaType};
69
70use std::sync::Arc;
71
72use crate::config::{ConfigKeySpec, ConnectorInfo};
73use crate::registry::ConnectorRegistry;
74
75/// Registers the Kafka source connector with the given registry.
76///
77/// # Errors
78///
79/// Returns an error if a Kafka source factory is already registered or the
80/// registry has been frozen.
81pub fn register_kafka_source(
82    registry: &ConnectorRegistry,
83) -> Result<(), crate::error::ConnectorError> {
84    let info = ConnectorInfo {
85        name: "kafka".to_string(),
86        display_name: "Apache Kafka Source".to_string(),
87        version: env!("CARGO_PKG_VERSION").to_string(),
88        is_source: true,
89        is_sink: false,
90        config_keys: kafka_source_config_keys(),
91    };
92
93    registry.register_source(
94        "kafka",
95        info,
96        Arc::new(|registry: Option<&Arc<prometheus::Registry>>| {
97            // Empty schema — filled in by discover_schema / open() / SQL DDL columns.
98            let empty = Arc::new(arrow_schema::Schema::empty());
99            Ok(Box::new(KafkaSource::new(
100                empty,
101                KafkaSourceConfig::default(),
102                registry.map(Arc::as_ref),
103            )))
104        }),
105    )
106}
107
108/// Registers the Kafka sink connector with the given registry.
109///
110/// # Errors
111///
112/// Returns an error if a Kafka sink factory is already registered or the
113/// registry has been frozen.
114pub fn register_kafka_sink(
115    registry: &ConnectorRegistry,
116) -> Result<(), crate::error::ConnectorError> {
117    let info = ConnectorInfo {
118        name: "kafka".to_string(),
119        display_name: "Apache Kafka Sink".to_string(),
120        version: env!("CARGO_PKG_VERSION").to_string(),
121        is_source: false,
122        is_sink: true,
123        config_keys: kafka_sink_config_keys(),
124    };
125
126    registry.register_sink(
127        "kafka",
128        info,
129        Arc::new(|_config, registry: Option<&Arc<prometheus::Registry>>| {
130            // Empty schema — the sink's schema is bound from the upstream query at build time.
131            let empty = Arc::new(arrow_schema::Schema::empty());
132            Ok(Box::new(KafkaSink::new(
133                empty,
134                KafkaSinkConfig::default(),
135                registry.map(Arc::as_ref),
136            )))
137        }),
138    )
139}
140
141/// Returns the configuration key specifications for the Kafka source.
142#[allow(clippy::too_many_lines)]
143fn kafka_source_config_keys() -> Vec<ConfigKeySpec> {
144    vec![
145        // Required
146        ConfigKeySpec::required("bootstrap.servers", "Kafka broker addresses"),
147        ConfigKeySpec::required("group.id", "Consumer group identifier"),
148        // The key schema cannot express alternatives; parsing requires one of these two.
149        ConfigKeySpec::optional(
150            "topic",
151            "Comma-separated topics (required unless topic.pattern is set)",
152            "",
153        ),
154        ConfigKeySpec::optional(
155            "topic.pattern",
156            "Topic regex (required unless topic is set)",
157            "",
158        ),
159        // Format
160        ConfigKeySpec::optional("format", "Data format (json/csv/avro/raw/debezium)", "json"),
161        // Security
162        ConfigKeySpec::optional(
163            "security.protocol",
164            "Security protocol (plaintext/ssl/sasl_plaintext/sasl_ssl)",
165            "plaintext",
166        ),
167        ConfigKeySpec::optional(
168            "sasl.mechanism",
169            "SASL mechanism (PLAIN/SCRAM-SHA-256/SCRAM-SHA-512/GSSAPI/OAUTHBEARER)",
170            "",
171        ),
172        ConfigKeySpec::optional("sasl.username", "SASL username for PLAIN/SCRAM", ""),
173        ConfigKeySpec::optional("sasl.password", "SASL password for PLAIN/SCRAM", ""),
174        ConfigKeySpec::optional("ssl.ca.location", "SSL CA certificate file path", ""),
175        ConfigKeySpec::optional(
176            "ssl.certificate.location",
177            "Client SSL certificate file path",
178            "",
179        ),
180        ConfigKeySpec::optional("ssl.key.location", "Client SSL private key file path", ""),
181        ConfigKeySpec::optional("ssl.key.password", "Password for encrypted SSL key", ""),
182        // Consumer tuning
183        ConfigKeySpec::optional(
184            "startup.mode",
185            "Startup mode (group-offsets/earliest/latest)",
186            "group-offsets",
187        ),
188        ConfigKeySpec::optional(
189            "startup.specific.offsets",
190            "Start from specific offsets (format: 'partition:offset,...')",
191            "",
192        ),
193        ConfigKeySpec::optional(
194            "startup.timestamp.ms",
195            "Start from timestamp (milliseconds since epoch)",
196            "",
197        ),
198        ConfigKeySpec::optional(
199            "auto.offset.reset",
200            "Fallback when no committed offset (earliest/latest/none)",
201            "earliest",
202        ),
203        ConfigKeySpec::optional(
204            "isolation.level",
205            "Transaction isolation (read_uncommitted/read_committed)",
206            "read_committed",
207        ),
208        ConfigKeySpec::optional("max.poll.records", "Max records per poll", "1000"),
209        ConfigKeySpec::optional(
210            "partition.assignment.strategy",
211            "Partition assignment (range/roundrobin/cooperative-sticky)",
212            "range",
213        ),
214        // Consumer group timing
215        ConfigKeySpec::optional(
216            "session.timeout.ms",
217            "Consumer session timeout in milliseconds (production-safe default)",
218            "45000",
219        ),
220        ConfigKeySpec::optional(
221            "heartbeat.interval.ms",
222            "Consumer heartbeat interval in milliseconds",
223            "10000",
224        ),
225        ConfigKeySpec::optional(
226            "queued.max.messages.kbytes",
227            "Max per-partition pre-fetch queue size in kbytes",
228            "16384",
229        ),
230        // Fetch tuning
231        ConfigKeySpec::optional("fetch.min.bytes", "Minimum bytes per fetch request", "1"),
232        ConfigKeySpec::optional(
233            "fetch.max.bytes",
234            "Maximum bytes per fetch request",
235            "52428800",
236        ),
237        ConfigKeySpec::optional(
238            "fetch.max.wait.ms",
239            "Max wait time for fetch.min.bytes",
240            "500",
241        ),
242        ConfigKeySpec::optional(
243            "max.partition.fetch.bytes",
244            "Max bytes per partition per fetch",
245            "1048576",
246        ),
247        // Metadata
248        ConfigKeySpec::optional(
249            "include.metadata",
250            "Include _partition/_offset/_timestamp columns",
251            "false",
252        ),
253        ConfigKeySpec::optional("include.headers", "Include _headers column", "false"),
254        // Backpressure
255        ConfigKeySpec::optional(
256            "backpressure.high.watermark",
257            "Channel fill ratio to pause",
258            "0.8",
259        ),
260        ConfigKeySpec::optional(
261            "backpressure.low.watermark",
262            "Channel fill ratio to resume",
263            "0.25",
264        ),
265        // Error handling
266        ConfigKeySpec::optional(
267            "max.deser.error.rate",
268            "Max tolerated deserialization error rate per batch (0.0-1.0)",
269            "0.5",
270        ),
271        // Schema Registry
272        ConfigKeySpec::optional(
273            "schema.registry.url",
274            "Confluent Schema Registry URL (required for Avro)",
275            "",
276        ),
277        ConfigKeySpec::optional("schema.registry.username", "Schema Registry username", ""),
278        ConfigKeySpec::optional("schema.registry.password", "Schema Registry password", ""),
279        ConfigKeySpec::optional(
280            "schema.registry.ssl.ca.location",
281            "Schema Registry SSL CA cert path",
282            "",
283        ),
284        ConfigKeySpec::optional(
285            "schema.registry.ssl.certificate.location",
286            "Schema Registry SSL client cert path",
287            "",
288        ),
289        ConfigKeySpec::optional(
290            "schema.registry.ssl.key.location",
291            "Schema Registry SSL client key path",
292            "",
293        ),
294        ConfigKeySpec::optional(
295            "schema.compatibility",
296            "Schema compatibility level override",
297            "",
298        ),
299        ConfigKeySpec::optional(
300            "schema.evolution.strategy",
301            "Runtime schema evolution handling (log/reject/ignore)",
302            "log",
303        ),
304        ConfigKeySpec::optional(
305            "schema.registry.subject.name.strategy",
306            "Schema Registry subject naming (topic-name/record-name/topic-record-name)",
307            "topic-name",
308        ),
309        ConfigKeySpec::optional(
310            "schema.registry.record.name",
311            "Avro record name for record-based subject naming",
312            "",
313        ),
314        ConfigKeySpec::optional(
315            "schema.registry.discovery.timeout.ms",
316            "Schema discovery timeout in milliseconds",
317            "10000",
318        ),
319        ConfigKeySpec::optional(
320            "max.poll.interval.ms",
321            "Maximum interval between consumer polls in milliseconds",
322            "600000",
323        ),
324        ConfigKeySpec::optional(
325            "broker.commit.on.checkpoint",
326            "Commit broker offsets after checkpoint completion",
327            "true",
328        ),
329        ConfigKeySpec::optional(
330            "reader.channel.capacity",
331            "Bounded reader channel capacity in records",
332            "8192",
333        ),
334    ]
335}
336
337/// Returns the configuration key specifications for the Kafka sink.
338fn kafka_sink_config_keys() -> Vec<ConfigKeySpec> {
339    vec![
340        // Required
341        ConfigKeySpec::required("bootstrap.servers", "Kafka broker addresses"),
342        ConfigKeySpec::required("topic", "Target Kafka topic"),
343        // Format
344        ConfigKeySpec::optional("format", "Serialization format (json/csv/avro/raw)", "json"),
345        // Security
346        ConfigKeySpec::optional(
347            "security.protocol",
348            "Security protocol (plaintext/ssl/sasl_plaintext/sasl_ssl)",
349            "plaintext",
350        ),
351        ConfigKeySpec::optional(
352            "sasl.mechanism",
353            "SASL mechanism (PLAIN/SCRAM-SHA-256/SCRAM-SHA-512/GSSAPI/OAUTHBEARER)",
354            "",
355        ),
356        ConfigKeySpec::optional("sasl.username", "SASL username for PLAIN/SCRAM", ""),
357        ConfigKeySpec::optional("sasl.password", "SASL password for PLAIN/SCRAM", ""),
358        ConfigKeySpec::optional("ssl.ca.location", "SSL CA certificate file path", ""),
359        ConfigKeySpec::optional(
360            "ssl.certificate.location",
361            "Client SSL certificate file path",
362            "",
363        ),
364        ConfigKeySpec::optional("ssl.key.location", "Client SSL private key file path", ""),
365        ConfigKeySpec::optional("ssl.key.password", "Password for encrypted SSL key", ""),
366        ConfigKeySpec::optional(
367            "max.in.flight.requests",
368            "Maximum in-flight producer requests per connection (1-5)",
369            "5",
370        ),
371        ConfigKeySpec::optional(
372            "delivery.timeout.ms",
373            "Delivery timeout in milliseconds",
374            "120000",
375        ),
376        // Partitioning
377        ConfigKeySpec::optional("key.column", "Column name to use as Kafka message key", ""),
378        ConfigKeySpec::optional("envelope", "Output envelope (append/upsert)", "append"),
379        ConfigKeySpec::optional(
380            "partitioner",
381            "Partitioning strategy (key-hash/round-robin/sticky)",
382            "key-hash",
383        ),
384        // Batching & Compression
385        ConfigKeySpec::optional("linger.ms", "Producer linger time in milliseconds", "5"),
386        ConfigKeySpec::optional("batch.size", "Producer batch size in bytes", "16384"),
387        ConfigKeySpec::optional("batch.num.messages", "Max messages per batch", "10000"),
388        ConfigKeySpec::optional(
389            "compression.type",
390            "Compression (none/gzip/snappy/lz4/zstd)",
391            "none",
392        ),
393        // Error Handling
394        ConfigKeySpec::optional(
395            "dlq.topic",
396            "Dead letter queue topic for failed records",
397            "",
398        ),
399        // Schema Registry
400        ConfigKeySpec::optional(
401            "schema.registry.url",
402            "Confluent Schema Registry URL (required for Avro)",
403            "",
404        ),
405        ConfigKeySpec::optional("schema.registry.username", "Schema Registry username", ""),
406        ConfigKeySpec::optional("schema.registry.password", "Schema Registry password", ""),
407        ConfigKeySpec::optional(
408            "schema.registry.ssl.ca.location",
409            "Schema Registry SSL CA cert path",
410            "",
411        ),
412        ConfigKeySpec::optional(
413            "schema.compatibility",
414            "Schema compatibility level override",
415            "",
416        ),
417    ]
418}
419
420/// Round-trip integration tests: serialize → deserialize → verify identity.
421#[cfg(test)]
422mod avro_roundtrip_tests {
423    use std::sync::Arc;
424
425    use arrow_array::{
426        BooleanArray, Float32Array, Float64Array, Int32Array, Int64Array, RecordBatch, StringArray,
427    };
428    use arrow_schema::{DataType, Field, Schema, SchemaRef};
429
430    use super::avro::AvroDeserializer;
431    use super::avro_serializer::AvroSerializer;
432    use super::schema_registry::arrow_to_avro_schema;
433    use crate::serde::{RecordDeserializer, RecordSerializer};
434
435    /// Serializes a batch, deserializes each record, and asserts equality.
436    fn roundtrip(batch: &RecordBatch, schema: &SchemaRef) -> RecordBatch {
437        let avro_schema_json =
438            arrow_to_avro_schema(schema, "roundtrip_test").expect("Arrow→Avro schema");
439
440        let ser = AvroSerializer::new(schema.clone(), 1);
441        let records = ser.serialize(batch).expect("serialize");
442
443        let mut deser = AvroDeserializer::new();
444        deser
445            .register_schema(1, &avro_schema_json)
446            .expect("register schema");
447
448        let record_refs: Vec<&[u8]> = records.iter().map(Vec::as_slice).collect();
449        deser
450            .deserialize_batch(&record_refs, schema)
451            .expect("deserialize")
452    }
453
454    #[test]
455    fn test_roundtrip_primitives() {
456        let schema = Arc::new(Schema::new(vec![
457            Field::new("id", DataType::Int64, false),
458            Field::new("name", DataType::Utf8, false),
459            Field::new("price", DataType::Float64, false),
460        ]));
461        let batch = RecordBatch::try_new(
462            schema.clone(),
463            vec![
464                Arc::new(Int64Array::from(vec![1, 2, 3])),
465                Arc::new(StringArray::from(vec!["AAPL", "GOOG", "MSFT"])),
466                Arc::new(Float64Array::from(vec![150.0, 2800.0, 300.0])),
467            ],
468        )
469        .unwrap();
470
471        let result = roundtrip(&batch, &schema);
472        assert_eq!(result.num_rows(), 3);
473        assert_eq!(result.num_columns(), 3);
474
475        let ids = result
476            .column(0)
477            .as_any()
478            .downcast_ref::<Int64Array>()
479            .unwrap();
480        assert_eq!(ids.value(0), 1);
481        assert_eq!(ids.value(1), 2);
482        assert_eq!(ids.value(2), 3);
483
484        let names = result
485            .column(1)
486            .as_any()
487            .downcast_ref::<StringArray>()
488            .unwrap();
489        assert_eq!(names.value(0), "AAPL");
490        assert_eq!(names.value(1), "GOOG");
491        assert_eq!(names.value(2), "MSFT");
492
493        let prices = result
494            .column(2)
495            .as_any()
496            .downcast_ref::<Float64Array>()
497            .unwrap();
498        assert!((prices.value(0) - 150.0).abs() < f64::EPSILON);
499        assert!((prices.value(1) - 2800.0).abs() < f64::EPSILON);
500    }
501
502    #[test]
503    fn test_roundtrip_all_primitive_types() {
504        let schema = Arc::new(Schema::new(vec![
505            Field::new("b", DataType::Boolean, false),
506            Field::new("i32", DataType::Int32, false),
507            Field::new("i64", DataType::Int64, false),
508            Field::new("f32", DataType::Float32, false),
509            Field::new("f64", DataType::Float64, false),
510            Field::new("s", DataType::Utf8, false),
511        ]));
512        let batch = RecordBatch::try_new(
513            schema.clone(),
514            vec![
515                Arc::new(BooleanArray::from(vec![true, false])),
516                Arc::new(Int32Array::from(vec![42, -1])),
517                Arc::new(Int64Array::from(vec![100_000_000, -999])),
518                Arc::new(Float32Array::from(vec![3.14f32, -0.001f32])),
519                Arc::new(Float64Array::from(vec![2.718, 1e10])),
520                Arc::new(StringArray::from(vec!["hello", "world"])),
521            ],
522        )
523        .unwrap();
524
525        let result = roundtrip(&batch, &schema);
526        assert_eq!(result.num_rows(), 2);
527        assert_eq!(result.num_columns(), 6);
528
529        let bools = result
530            .column(0)
531            .as_any()
532            .downcast_ref::<BooleanArray>()
533            .unwrap();
534        assert!(bools.value(0));
535        assert!(!bools.value(1));
536
537        let ints = result
538            .column(1)
539            .as_any()
540            .downcast_ref::<Int32Array>()
541            .unwrap();
542        assert_eq!(ints.value(0), 42);
543        assert_eq!(ints.value(1), -1);
544    }
545
546    #[test]
547    fn test_roundtrip_single_row() {
548        let schema = Arc::new(Schema::new(vec![
549            Field::new("id", DataType::Int64, false),
550            Field::new("val", DataType::Utf8, false),
551        ]));
552        let batch = RecordBatch::try_new(
553            schema.clone(),
554            vec![
555                Arc::new(Int64Array::from(vec![99])),
556                Arc::new(StringArray::from(vec!["single"])),
557            ],
558        )
559        .unwrap();
560
561        let result = roundtrip(&batch, &schema);
562        assert_eq!(result.num_rows(), 1);
563        let val = result
564            .column(1)
565            .as_any()
566            .downcast_ref::<StringArray>()
567            .unwrap();
568        assert_eq!(val.value(0), "single");
569    }
570
571    #[test]
572    fn test_roundtrip_confluent_wire_format() {
573        let schema = Arc::new(Schema::new(vec![
574            Field::new("id", DataType::Int64, false),
575            Field::new("name", DataType::Utf8, false),
576        ]));
577        let batch = RecordBatch::try_new(
578            schema.clone(),
579            vec![
580                Arc::new(Int64Array::from(vec![1, 2])),
581                Arc::new(StringArray::from(vec!["a", "b"])),
582            ],
583        )
584        .unwrap();
585
586        let ser = AvroSerializer::new(schema.clone(), 42);
587        let records = ser.serialize(&batch).unwrap();
588
589        // Verify Confluent wire format: 0x00 + 4-byte BE schema ID.
590        for record in &records {
591            assert!(record.len() >= 5, "record too short");
592            assert_eq!(record[0], 0x00, "magic byte");
593            let schema_id = u32::from_be_bytes([record[1], record[2], record[3], record[4]]);
594            assert_eq!(schema_id, 42, "schema ID in header");
595        }
596
597        // Also verify round-trip works with schema ID 42.
598        let avro_schema_json = arrow_to_avro_schema(&schema, "test").unwrap();
599        let mut deser = AvroDeserializer::new();
600        deser.register_schema(42, &avro_schema_json).unwrap();
601
602        let record_refs: Vec<&[u8]> = records.iter().map(Vec::as_slice).collect();
603        let result = deser.deserialize_batch(&record_refs, &schema).unwrap();
604        assert_eq!(result.num_rows(), 2);
605    }
606
607    #[test]
608    fn test_roundtrip_empty_batch() {
609        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
610        let batch = RecordBatch::new_empty(schema.clone());
611
612        let ser = AvroSerializer::new(schema.clone(), 1);
613        let records = ser.serialize(&batch).unwrap();
614        assert!(records.is_empty());
615    }
616
617    #[test]
618    fn test_roundtrip_many_rows() {
619        let schema = Arc::new(Schema::new(vec![
620            Field::new("idx", DataType::Int64, false),
621            Field::new("label", DataType::Utf8, false),
622        ]));
623        let n = 100;
624        let ids: Vec<i64> = (0..n).collect();
625        let labels: Vec<String> = (0..n).map(|i| format!("row-{i}")).collect();
626        let batch = RecordBatch::try_new(
627            schema.clone(),
628            vec![
629                Arc::new(Int64Array::from(ids)),
630                Arc::new(StringArray::from(labels)),
631            ],
632        )
633        .unwrap();
634
635        let result = roundtrip(&batch, &schema);
636        assert_eq!(result.num_rows(), n as usize);
637
638        let ids = result
639            .column(0)
640            .as_any()
641            .downcast_ref::<Int64Array>()
642            .unwrap();
643        for i in 0..n as usize {
644            assert_eq!(ids.value(i), i as i64);
645        }
646    }
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652
653    #[test]
654    fn test_register_kafka_source() {
655        let registry = ConnectorRegistry::new();
656        register_kafka_source(&registry).unwrap();
657
658        let sources = registry.list_sources();
659        assert!(sources.contains(&"kafka".to_string()));
660
661        let info = registry.source_info("kafka");
662        assert!(info.is_some());
663        let info = info.unwrap();
664        assert_eq!(info.name, "kafka");
665        assert!(info.is_source);
666        assert!(!info.is_sink);
667        let required: Vec<&str> = info
668            .config_keys
669            .iter()
670            .filter(|key| key.required)
671            .map(|key| key.key.as_str())
672            .collect();
673        assert!(required.contains(&"bootstrap.servers"));
674        assert!(required.contains(&"group.id"));
675        assert!(!required.contains(&"topic"));
676        assert!(!required.contains(&"topic.pattern"));
677        assert!(info.config_keys.iter().any(|key| key.key == "topic"));
678        assert!(info
679            .config_keys
680            .iter()
681            .any(|key| key.key == "topic.pattern"));
682        for supported in [
683            "schema.registry.subject.name.strategy",
684            "schema.registry.record.name",
685            "schema.registry.discovery.timeout.ms",
686            "max.poll.interval.ms",
687            "broker.commit.on.checkpoint",
688            "reader.channel.capacity",
689        ] {
690            assert!(
691                info.config_keys.iter().any(|key| key.key == supported),
692                "registered Kafka source descriptor omits {supported}"
693            );
694        }
695    }
696
697    #[test]
698    fn test_factory_creates_source() {
699        let registry = ConnectorRegistry::new();
700        register_kafka_source(&registry).unwrap();
701
702        let config = crate::config::ConnectorConfig::new("kafka");
703        let source = registry.create_source(&config, None);
704        assert!(source.is_ok());
705    }
706
707    #[test]
708    fn test_register_kafka_sink() {
709        let registry = ConnectorRegistry::new();
710        register_kafka_sink(&registry).unwrap();
711
712        let sinks = registry.list_sinks();
713        assert!(sinks.contains(&"kafka".to_string()));
714
715        let info = registry.sink_info("kafka");
716        assert!(info.is_some());
717        let info = info.unwrap();
718        assert_eq!(info.name, "kafka");
719        assert!(!info.is_source);
720        assert!(info.is_sink);
721        assert!(info.config_keys.iter().any(|key| key.key == "envelope"));
722        assert!(info.config_keys.iter().any(|key| key.key == "key.column"));
723        assert!(!info
724            .config_keys
725            .iter()
726            .any(|key| key.key == "delivery.guarantee"));
727    }
728
729    #[test]
730    fn test_factory_creates_sink() {
731        let registry = ConnectorRegistry::new();
732        register_kafka_sink(&registry).unwrap();
733
734        let config = crate::config::ConnectorConfig::new("kafka");
735        let sink = registry.create_sink(&config, None);
736        assert!(sink.is_ok());
737    }
738}