1use crate::auth_catalog::{self, AuthCatalog};
9use crate::error::{CliError, CliResult};
10use faucet_core::{Sink, Source};
11use serde::de::DeserializeOwned;
12use serde_json::Value;
13use std::collections::BTreeMap;
14use std::sync::{Arc, OnceLock};
15
16pub type SourceFactory = Arc<dyn Fn(Value) -> CliResult<Box<dyn Source>> + Send + Sync>;
26
27pub type SinkFactory = Arc<dyn Fn(Value) -> CliResult<Box<dyn Sink>> + Send + Sync>;
30
31pub type SchemaFn = Arc<dyn Fn() -> Value + Send + Sync>;
35
36struct SourceEntry {
37 factory: SourceFactory,
38 schema: SchemaFn,
39 description: &'static str,
40}
41
42struct SinkEntry {
43 factory: SinkFactory,
44 schema: SchemaFn,
45 description: &'static str,
46}
47
48#[derive(Default)]
77pub struct PluginRegistry {
78 sources: BTreeMap<&'static str, SourceEntry>,
79 sinks: BTreeMap<&'static str, SinkEntry>,
80 errors: Vec<String>,
84}
85
86impl PluginRegistry {
87 pub fn new() -> Self {
89 Self::default()
90 }
91
92 pub fn with_builtins() -> Self {
98 Self::default()
99 }
100
101 #[must_use]
105 pub fn register_source<F>(self, name: &str, factory: F) -> Self
106 where
107 F: Fn(Value) -> CliResult<Box<dyn Source>> + Send + Sync + 'static,
108 {
109 self.register_source_with(name, factory, || serde_json::json!({"type": "object"}), "")
110 }
111
112 #[must_use]
115 pub fn register_source_with<F, S>(
116 mut self,
117 name: &str,
118 factory: F,
119 schema: S,
120 description: &str,
121 ) -> Self
122 where
123 F: Fn(Value) -> CliResult<Box<dyn Source>> + Send + Sync + 'static,
124 S: Fn() -> Value + Send + Sync + 'static,
125 {
126 let key = leak_str(name);
127 if builtin_source_descriptions().iter().any(|(n, _)| *n == key) {
128 self.errors.push(format!(
129 "cannot register source `{name}`: a built-in source already uses that name"
130 ));
131 return self;
132 }
133 if self.sources.contains_key(key) {
134 self.errors
135 .push(format!("source `{name}` is registered more than once"));
136 return self;
137 }
138 self.sources.insert(
139 key,
140 SourceEntry {
141 factory: Arc::new(factory),
142 schema: Arc::new(schema),
143 description: leak_str(description),
144 },
145 );
146 self
147 }
148
149 #[must_use]
151 pub fn register_sink<F>(self, name: &str, factory: F) -> Self
152 where
153 F: Fn(Value) -> CliResult<Box<dyn Sink>> + Send + Sync + 'static,
154 {
155 self.register_sink_with(name, factory, || serde_json::json!({"type": "object"}), "")
156 }
157
158 #[must_use]
160 pub fn register_sink_with<F, S>(
161 mut self,
162 name: &str,
163 factory: F,
164 schema: S,
165 description: &str,
166 ) -> Self
167 where
168 F: Fn(Value) -> CliResult<Box<dyn Sink>> + Send + Sync + 'static,
169 S: Fn() -> Value + Send + Sync + 'static,
170 {
171 let key = leak_str(name);
172 if builtin_sink_descriptions().iter().any(|(n, _)| *n == key) {
173 self.errors.push(format!(
174 "cannot register sink `{name}`: a built-in sink already uses that name"
175 ));
176 return self;
177 }
178 if self.sinks.contains_key(key) {
179 self.errors
180 .push(format!("sink `{name}` is registered more than once"));
181 return self;
182 }
183 self.sinks.insert(
184 key,
185 SinkEntry {
186 factory: Arc::new(factory),
187 schema: Arc::new(schema),
188 description: leak_str(description),
189 },
190 );
191 self
192 }
193
194 pub fn install(self) -> CliResult<()> {
198 if !self.errors.is_empty() {
199 return Err(CliError::Config(self.errors.join("; ")));
200 }
201 GLOBAL_REGISTRY
202 .set(self)
203 .map_err(|_| CliError::Config("connector registry already installed".to_owned()))
204 }
205
206 fn custom_source_descriptions(&self) -> Vec<(&'static str, &'static str)> {
207 self.sources
208 .iter()
209 .map(|(name, e)| {
210 (
211 *name,
212 if e.description.is_empty() {
213 "custom source connector"
214 } else {
215 e.description
216 },
217 )
218 })
219 .collect()
220 }
221
222 fn custom_sink_descriptions(&self) -> Vec<(&'static str, &'static str)> {
223 self.sinks
224 .iter()
225 .map(|(name, e)| {
226 (
227 *name,
228 if e.description.is_empty() {
229 "custom sink connector"
230 } else {
231 e.description
232 },
233 )
234 })
235 .collect()
236 }
237}
238
239fn leak_str(s: &str) -> &'static str {
244 Box::leak(s.to_owned().into_boxed_str())
245}
246
247static GLOBAL_REGISTRY: OnceLock<PluginRegistry> = OnceLock::new();
248
249fn global() -> &'static PluginRegistry {
252 GLOBAL_REGISTRY.get_or_init(PluginRegistry::default)
253}
254
255pub async fn build_source(
259 kind: &str,
260 config: Value,
261 auth: &AuthCatalog,
262 retry_policy: Option<&faucet_core::RetryPolicy>,
263) -> CliResult<Box<dyn Source>> {
264 if let Some(entry) = global().sources.get(kind) {
270 return (entry.factory)(config);
271 }
272 let auth_ref = auth_catalog::auth_ref(&config);
273 match kind {
274 #[cfg(feature = "source-rest")]
275 "rest" => {
276 let cfg = decode::<faucet_source_rest::RestStreamConfig>("source", "rest", config)?;
277 let mut s = faucet_source_rest::RestStream::new(cfg)?;
278 if let Some(name) = &auth_ref {
279 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
280 }
281 if let Some(rp) = retry_policy {
282 s = s.with_retry_policy(rp.clone());
283 }
284 Ok(Box::new(s))
285 }
286 #[cfg(feature = "source-graphql")]
287 "graphql" => {
288 let cfg =
289 decode::<faucet_source_graphql::GraphqlStreamConfig>("source", "graphql", config)?;
290 let mut s = faucet_source_graphql::GraphqlStream::new(cfg);
291 if let Some(name) = &auth_ref {
292 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
293 }
294 if let Some(rp) = retry_policy {
295 s = s.with_retry_policy(rp.clone());
296 }
297 Ok(Box::new(s))
298 }
299 #[cfg(feature = "source-xml")]
300 "xml" => {
301 let cfg = decode::<faucet_source_xml::XmlStreamConfig>("source", "xml", config)?;
302 let mut s = faucet_source_xml::XmlStream::new(cfg);
303 if let Some(name) = &auth_ref {
304 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
305 }
306 if let Some(rp) = retry_policy {
307 s = s.with_retry_policy(rp.clone());
308 }
309 Ok(Box::new(s))
310 }
311 #[cfg(feature = "source-grpc")]
312 "grpc" => {
313 let cfg = decode::<faucet_source_grpc::GrpcStreamConfig>("source", "grpc", config)?;
314 let mut s = faucet_source_grpc::GrpcStream::new(cfg)?;
315 if let Some(name) = &auth_ref {
316 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
317 }
318 Ok(Box::new(s))
319 }
320 #[cfg(feature = "source-postgres")]
321 "postgres" => {
322 let cfg = decode::<faucet_source_postgres::PostgresSourceConfig>(
323 "source", "postgres", config,
324 )?;
325 Ok(Box::new(
326 faucet_source_postgres::PostgresSource::new(cfg).await?,
327 ))
328 }
329 #[cfg(feature = "source-postgres-cdc")]
330 "postgres-cdc" => {
331 let cfg = decode::<faucet_source_postgres_cdc::PostgresCdcSourceConfig>(
332 "source",
333 "postgres-cdc",
334 config,
335 )?;
336 Ok(Box::new(
337 faucet_source_postgres_cdc::PostgresCdcSource::new(cfg).await?,
338 ))
339 }
340 #[cfg(feature = "source-mysql")]
341 "mysql" => {
342 let cfg = decode::<faucet_source_mysql::MysqlSourceConfig>("source", "mysql", config)?;
343 Ok(Box::new(faucet_source_mysql::MysqlSource::new(cfg).await?))
344 }
345 #[cfg(feature = "source-mssql")]
346 "mssql" => {
347 let cfg = decode::<faucet_source_mssql::MssqlSourceConfig>("source", "mssql", config)?;
348 Ok(Box::new(faucet_source_mssql::MssqlSource::new(cfg).await?))
349 }
350 #[cfg(feature = "source-sqlite")]
351 "sqlite" => {
352 let cfg =
353 decode::<faucet_source_sqlite::SqliteSourceConfig>("source", "sqlite", config)?;
354 Ok(Box::new(
355 faucet_source_sqlite::SqliteSource::new(cfg).await?,
356 ))
357 }
358 #[cfg(feature = "source-s3")]
359 "s3" => {
360 let cfg = decode::<faucet_source_s3::S3SourceConfig>("source", "s3", config)?;
361 Ok(Box::new(faucet_source_s3::S3Source::new(cfg).await?))
362 }
363 #[cfg(feature = "source-mongodb")]
364 "mongodb" => {
365 let cfg =
366 decode::<faucet_source_mongodb::MongoSourceConfig>("source", "mongodb", config)?;
367 Ok(Box::new(
368 faucet_source_mongodb::MongoSource::new(cfg).await?,
369 ))
370 }
371 #[cfg(feature = "source-mongodb-cdc")]
372 "mongodb-cdc" => {
373 let cfg = decode::<faucet_source_mongodb_cdc::MongoCdcSourceConfig>(
374 "source",
375 "mongodb-cdc",
376 config,
377 )?;
378 Ok(Box::new(
379 faucet_source_mongodb_cdc::MongoCdcSource::new(cfg).await?,
380 ))
381 }
382 #[cfg(feature = "source-mysql-cdc")]
383 "mysql-cdc" => {
384 let cfg = decode::<faucet_source_mysql_cdc::MysqlCdcSourceConfig>(
385 "source",
386 "mysql-cdc",
387 config,
388 )?;
389 Ok(Box::new(
390 faucet_source_mysql_cdc::MysqlCdcSource::new(cfg).await?,
391 ))
392 }
393 #[cfg(feature = "source-redis")]
394 "redis" => {
395 let cfg = decode::<faucet_source_redis::RedisSourceConfig>("source", "redis", config)?;
396 Ok(Box::new(faucet_source_redis::RedisSource::new(cfg)?))
397 }
398 #[cfg(feature = "source-webhook")]
399 "webhook" => {
400 let cfg =
401 decode::<faucet_source_webhook::WebhookSourceConfig>("source", "webhook", config)?;
402 Ok(Box::new(faucet_source_webhook::WebhookSource::new(cfg)))
403 }
404 #[cfg(feature = "source-websocket")]
405 "websocket" => {
406 let cfg = decode::<faucet_source_websocket::WebsocketSourceConfig>(
407 "source",
408 "websocket",
409 config,
410 )?;
411 let mut s = faucet_source_websocket::WebsocketSource::new(cfg)?;
412 if let Some(name) = &auth_ref {
413 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
414 }
415 Ok(Box::new(s))
416 }
417 #[cfg(feature = "source-csv")]
418 "csv" => {
419 let cfg = decode::<faucet_source_csv::CsvSourceConfig>("source", "csv", config)?;
420 Ok(Box::new(faucet_source_csv::CsvSource::new(cfg)))
421 }
422 #[cfg(feature = "source-singer")]
423 "singer" => {
424 let cfg =
425 decode::<faucet_source_singer::SingerSourceConfig>("source", "singer", config)?;
426 Ok(Box::new(faucet_source_singer::SingerSource::new(cfg)))
427 }
428 #[cfg(feature = "source-elasticsearch")]
429 "elasticsearch" => {
430 let cfg = decode::<faucet_source_elasticsearch::ElasticsearchSourceConfig>(
431 "source",
432 "elasticsearch",
433 config,
434 )?;
435 let mut s = faucet_source_elasticsearch::ElasticsearchSource::new(cfg)?;
436 if let Some(name) = &auth_ref {
437 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
438 }
439 Ok(Box::new(s))
440 }
441 #[cfg(feature = "source-kafka")]
442 "kafka" => {
443 let cfg = decode::<faucet_source_kafka::KafkaSourceConfig>("source", "kafka", config)?;
444 Ok(Box::new(faucet_source_kafka::KafkaSource::new(cfg).await?))
445 }
446 #[cfg(feature = "source-kinesis")]
447 "kinesis" => {
448 let cfg =
449 decode::<faucet_source_kinesis::KinesisSourceConfig>("source", "kinesis", config)?;
450 Ok(Box::new(
451 faucet_source_kinesis::KinesisSource::new(cfg).await?,
452 ))
453 }
454 #[cfg(feature = "source-spanner")]
455 "spanner" => {
456 let cfg =
457 decode::<faucet_source_spanner::SpannerSourceConfig>("source", "spanner", config)?;
458 Ok(Box::new(
459 faucet_source_spanner::SpannerSource::new(cfg).await?,
460 ))
461 }
462 #[cfg(feature = "source-parquet")]
463 "parquet" => {
464 let cfg =
465 decode::<faucet_source_parquet::ParquetSourceConfig>("source", "parquet", config)?;
466 Ok(Box::new(
467 faucet_source_parquet::ParquetSource::new(cfg).await?,
468 ))
469 }
470 #[cfg(feature = "source-delta")]
471 "delta" => {
472 let cfg = decode::<faucet_source_delta::DeltaSourceConfig>("source", "delta", config)?;
473 Ok(Box::new(faucet_source_delta::DeltaSource::new(cfg).await?))
474 }
475 #[cfg(feature = "source-databricks")]
476 "databricks" => {
477 let cfg = decode::<faucet_source_databricks::DatabricksSourceConfig>(
478 "source",
479 "databricks",
480 config,
481 )?;
482 let mut s = faucet_source_databricks::DatabricksSource::new(cfg)?;
483 if let Some(name) = &auth_ref {
484 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
485 }
486 Ok(Box::new(s))
487 }
488 #[cfg(feature = "source-gcs")]
489 "gcs" => {
490 let cfg = decode::<faucet_source_gcs::GcsSourceConfig>("source", "gcs", config)?;
491 Ok(Box::new(faucet_source_gcs::GcsSource::new(cfg).await?))
492 }
493 #[cfg(feature = "source-bigquery")]
494 "bigquery" => {
495 let cfg = decode::<faucet_source_bigquery::BigQuerySourceConfig>(
496 "source", "bigquery", config,
497 )?;
498 Ok(Box::new(
499 faucet_source_bigquery::BigQuerySource::new(cfg).await?,
500 ))
501 }
502 #[cfg(feature = "source-snowflake")]
503 "snowflake" => {
504 let cfg = decode::<faucet_source_snowflake::SnowflakeSourceConfig>(
505 "source",
506 "snowflake",
507 config,
508 )?;
509 let mut s = faucet_source_snowflake::SnowflakeSource::new(cfg)?;
510 if let Some(name) = &auth_ref {
511 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
512 }
513 Ok(Box::new(s))
514 }
515 #[cfg(feature = "source-mssql-cdc")]
516 "mssql-cdc" => {
517 let cfg = decode::<faucet_source_mssql_cdc::MssqlCdcSourceConfig>(
518 "source",
519 "mssql-cdc",
520 config,
521 )?;
522 Ok(Box::new(
523 faucet_source_mssql_cdc::MssqlCdcSource::new(cfg).await?,
524 ))
525 }
526 #[cfg(feature = "source-redshift")]
527 "redshift" => {
528 let cfg = decode::<faucet_source_redshift::RedshiftSourceConfig>(
529 "source", "redshift", config,
530 )?;
531 Ok(Box::new(faucet_source_redshift::RedshiftSource::new(cfg)?))
532 }
533 #[cfg(feature = "source-pubsub")]
534 "pubsub" => {
535 let cfg =
536 decode::<faucet_source_pubsub::PubsubSourceConfig>("source", "pubsub", config)?;
537 Ok(Box::new(
538 faucet_source_pubsub::PubsubSource::new(cfg).await?,
539 ))
540 }
541 #[cfg(feature = "source-clickhouse")]
542 "clickhouse" => {
543 let cfg = decode::<faucet_source_clickhouse::ClickHouseSourceConfig>(
544 "source",
545 "clickhouse",
546 config,
547 )?;
548 Ok(Box::new(faucet_source_clickhouse::ClickHouseSource::new(
549 cfg,
550 )?))
551 }
552 #[cfg(feature = "source-azure-blob")]
553 "azure-blob" => {
554 let cfg = decode::<faucet_source_azure_blob::AzureBlobSourceConfig>(
555 "source",
556 "azure-blob",
557 config,
558 )?;
559 Ok(Box::new(
560 faucet_source_azure_blob::AzureBlobSource::new(cfg).await?,
561 ))
562 }
563 other => Err(unknown(other, "source", source_kinds())),
564 }
565}
566
567pub async fn build_sink(kind: &str, config: Value, auth: &AuthCatalog) -> CliResult<Box<dyn Sink>> {
571 if let Some(entry) = global().sinks.get(kind) {
572 return (entry.factory)(config);
573 }
574 let auth_ref = auth_catalog::auth_ref(&config);
575 match kind {
576 #[cfg(feature = "sink-bigquery")]
577 "bigquery" => {
578 let cfg =
579 decode::<faucet_sink_bigquery::BigQuerySinkConfig>("sink", "bigquery", config)?;
580 Ok(Box::new(
581 faucet_sink_bigquery::BigQuerySink::new(cfg).await?,
582 ))
583 }
584 #[cfg(feature = "sink-iceberg")]
585 "iceberg" => {
586 let cfg = decode::<faucet_sink_iceberg::IcebergSinkConfig>("sink", "iceberg", config)?;
587 Ok(Box::new(faucet_sink_iceberg::IcebergSink::new(cfg).await?))
588 }
589 #[cfg(feature = "sink-delta")]
590 "delta" => {
591 let cfg = decode::<faucet_sink_delta::DeltaSinkConfig>("sink", "delta", config)?;
592 Ok(Box::new(faucet_sink_delta::DeltaSink::new(cfg).await?))
593 }
594 #[cfg(feature = "sink-postgres")]
595 "postgres" => {
596 let cfg =
597 decode::<faucet_sink_postgres::PostgresSinkConfig>("sink", "postgres", config)?;
598 Ok(Box::new(
599 faucet_sink_postgres::PostgresSink::new(cfg).await?,
600 ))
601 }
602 #[cfg(feature = "sink-jsonl")]
603 "jsonl" => {
604 let cfg = decode::<faucet_sink_jsonl::JsonlSinkConfig>("sink", "jsonl", config)?;
605 Ok(Box::new(faucet_sink_jsonl::JsonlSink::new(cfg)))
606 }
607 #[cfg(feature = "sink-snowflake")]
608 "snowflake" => {
609 let cfg =
610 decode::<faucet_sink_snowflake::SnowflakeSinkConfig>("sink", "snowflake", config)?;
611 let mut s = faucet_sink_snowflake::SnowflakeSink::new(cfg)?;
612 if let Some(name) = &auth_ref {
613 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
614 }
615 Ok(Box::new(s))
616 }
617 #[cfg(feature = "sink-mysql")]
618 "mysql" => {
619 let cfg = decode::<faucet_sink_mysql::MysqlSinkConfig>("sink", "mysql", config)?;
620 Ok(Box::new(faucet_sink_mysql::MysqlSink::new(cfg).await?))
621 }
622 #[cfg(feature = "sink-mssql")]
623 "mssql" => {
624 let cfg = decode::<faucet_sink_mssql::MssqlSinkConfig>("sink", "mssql", config)?;
625 Ok(Box::new(faucet_sink_mssql::MssqlSink::new(cfg).await?))
626 }
627 #[cfg(feature = "sink-sqlite")]
628 "sqlite" => {
629 let cfg = decode::<faucet_sink_sqlite::SqliteSinkConfig>("sink", "sqlite", config)?;
630 Ok(Box::new(faucet_sink_sqlite::SqliteSink::new(cfg).await?))
631 }
632 #[cfg(feature = "sink-s3")]
633 "s3" => {
634 let cfg = decode::<faucet_sink_s3::S3SinkConfig>("sink", "s3", config)?;
635 Ok(Box::new(faucet_sink_s3::S3Sink::new(cfg).await?))
636 }
637 #[cfg(feature = "sink-mongodb")]
638 "mongodb" => {
639 let cfg = decode::<faucet_sink_mongodb::MongoSinkConfig>("sink", "mongodb", config)?;
640 Ok(Box::new(faucet_sink_mongodb::MongoSink::new(cfg).await?))
641 }
642 #[cfg(feature = "sink-redis")]
643 "redis" => {
644 let cfg = decode::<faucet_sink_redis::RedisSinkConfig>("sink", "redis", config)?;
645 Ok(Box::new(faucet_sink_redis::RedisSink::new(cfg).await?))
646 }
647 #[cfg(feature = "sink-csv")]
648 "csv" => {
649 let cfg = decode::<faucet_sink_csv::CsvSinkConfig>("sink", "csv", config)?;
650 Ok(Box::new(faucet_sink_csv::CsvSink::new(cfg)))
651 }
652 #[cfg(feature = "sink-elasticsearch")]
653 "elasticsearch" => {
654 let cfg = decode::<faucet_sink_elasticsearch::ElasticsearchSinkConfig>(
655 "sink",
656 "elasticsearch",
657 config,
658 )?;
659 let mut s = faucet_sink_elasticsearch::ElasticsearchSink::new(cfg)?;
660 if let Some(name) = &auth_ref {
661 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
662 }
663 Ok(Box::new(s))
664 }
665 #[cfg(feature = "sink-kafka")]
666 "kafka" => {
667 let cfg = decode::<faucet_sink_kafka::KafkaSinkConfig>("sink", "kafka", config)?;
668 Ok(Box::new(faucet_sink_kafka::KafkaSink::new(cfg).await?))
669 }
670 #[cfg(feature = "sink-kinesis")]
671 "kinesis" => {
672 let cfg = decode::<faucet_sink_kinesis::KinesisSinkConfig>("sink", "kinesis", config)?;
673 Ok(Box::new(faucet_sink_kinesis::KinesisSink::new(cfg).await?))
674 }
675 #[cfg(feature = "sink-spanner")]
676 "spanner" => {
677 let cfg = decode::<faucet_sink_spanner::SpannerSinkConfig>("sink", "spanner", config)?;
678 Ok(Box::new(faucet_sink_spanner::SpannerSink::new(cfg).await?))
679 }
680 #[cfg(feature = "sink-http")]
681 "http" => {
682 let cfg = decode::<faucet_sink_http::HttpSinkConfig>("sink", "http", config)?;
683 let mut s = faucet_sink_http::HttpSink::new(cfg);
684 if let Some(name) = &auth_ref {
685 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
686 }
687 Ok(Box::new(s))
688 }
689 #[cfg(feature = "sink-stdout")]
690 "stdout" => {
691 let cfg = decode::<faucet_sink_stdout::StdoutSinkConfig>("sink", "stdout", config)?;
692 Ok(Box::new(faucet_sink_stdout::StdoutSink::new(cfg)))
693 }
694 #[cfg(feature = "sink-parquet")]
695 "parquet" => {
696 let cfg = decode::<faucet_sink_parquet::ParquetSinkConfig>("sink", "parquet", config)?;
697 Ok(Box::new(faucet_sink_parquet::ParquetSink::new(cfg).await?))
698 }
699 #[cfg(feature = "sink-gcs")]
700 "gcs" => {
701 let cfg = decode::<faucet_sink_gcs::GcsSinkConfig>("sink", "gcs", config)?;
702 Ok(Box::new(faucet_sink_gcs::GcsSink::new(cfg).await?))
703 }
704 #[cfg(feature = "sink-redshift")]
705 "redshift" => {
706 let cfg =
707 decode::<faucet_sink_redshift::RedshiftSinkConfig>("sink", "redshift", config)?;
708 Ok(Box::new(
709 faucet_sink_redshift::RedshiftSink::new(cfg).await?,
710 ))
711 }
712 #[cfg(feature = "sink-pubsub")]
713 "pubsub" => {
714 let cfg = decode::<faucet_sink_pubsub::PubsubSinkConfig>("sink", "pubsub", config)?;
715 Ok(Box::new(faucet_sink_pubsub::PubsubSink::new(cfg).await?))
716 }
717 #[cfg(feature = "sink-clickhouse")]
718 "clickhouse" => {
719 let cfg = decode::<faucet_sink_clickhouse::ClickHouseSinkConfig>(
720 "sink",
721 "clickhouse",
722 config,
723 )?;
724 Ok(Box::new(faucet_sink_clickhouse::ClickHouseSink::new(cfg)?))
725 }
726 #[cfg(feature = "sink-azure-blob")]
727 "azure-blob" => {
728 let cfg = decode::<faucet_sink_azure_blob::AzureBlobSinkConfig>(
729 "sink",
730 "azure-blob",
731 config,
732 )?;
733 Ok(Box::new(
734 faucet_sink_azure_blob::AzureBlobSink::new(cfg).await?,
735 ))
736 }
737 other => Err(unknown(other, "sink", sink_kinds())),
738 }
739}
740
741pub const EXACTLY_ONCE_SOURCE_KINDS: &[&str] = &[
748 "postgres-cdc",
749 "mysql-cdc",
750 "mssql-cdc",
751 "mongodb-cdc",
752 "kafka",
753];
754
755pub const IDEMPOTENT_SINK_KINDS: &[&str] = &[
760 "sqlite",
761 "postgres",
762 "mysql",
763 "mssql",
764 "iceberg",
765 "bigquery",
766 "kafka",
767 "snowflake",
768 "redis",
769 "mongodb",
770 "spanner",
771];
772
773pub const SCHEMA_EVOLUTION_SINK_KINDS: &[&str] = &[
777 "postgres",
778 "mysql",
779 "mssql",
780 "sqlite",
781 "bigquery",
782 "elasticsearch",
783 "spanner",
784];
785
786pub const UPSERT_SINK_KINDS: &[&str] = &[
790 "postgres",
791 "sqlite",
792 "mysql",
793 "mssql",
794 "mongodb",
795 "elasticsearch",
796 "bigquery",
797 "spanner",
798];
799
800pub const DISCOVER_SOURCE_KINDS: &[&str] = &[
804 "postgres",
805 "mysql",
806 "mssql",
807 "sqlite",
808 "mongodb",
809 "elasticsearch",
810 "bigquery",
811 "snowflake",
812 "spanner",
813 "s3",
814 "gcs",
815];
816
817pub fn source_supports_discover(kind: &str) -> bool {
819 DISCOVER_SOURCE_KINDS.contains(&kind)
820}
821
822pub fn source_replay_guarantee(kind: &str) -> faucet_core::ReplayGuarantee {
827 if EXACTLY_ONCE_SOURCE_KINDS.contains(&kind) {
828 faucet_core::ReplayGuarantee::Deterministic
829 } else {
830 faucet_core::ReplayGuarantee::NonDeterministic
831 }
832}
833
834pub fn sink_guarantee(kind: &str) -> faucet_core::SinkGuarantee {
838 if IDEMPOTENT_SINK_KINDS.contains(&kind) {
839 faucet_core::SinkGuarantee::AtomicWatermark
840 } else if UPSERT_SINK_KINDS.contains(&kind) {
841 faucet_core::SinkGuarantee::KeyedUpsert
842 } else {
843 faucet_core::SinkGuarantee::AtLeastOnce
844 }
845}
846
847pub fn source_supports_exactly_once(kind: &str) -> bool {
849 source_replay_guarantee(kind) == faucet_core::ReplayGuarantee::Deterministic
850}
851
852pub fn sink_supports_idempotent_writes(kind: &str) -> bool {
854 sink_guarantee(kind) == faucet_core::SinkGuarantee::AtomicWatermark
855}
856
857pub fn sink_supports_schema_evolution(kind: &str) -> bool {
859 SCHEMA_EVOLUTION_SINK_KINDS.contains(&kind)
860}
861
862pub fn sink_supported_write_modes(kind: &str) -> &'static [faucet_core::WriteMode] {
865 use faucet_core::WriteMode;
866 if UPSERT_SINK_KINDS.contains(&kind) {
867 &[WriteMode::Append, WriteMode::Upsert, WriteMode::Delete]
868 } else {
869 &[WriteMode::Append]
870 }
871}
872
873pub fn source_schema(kind: &str) -> CliResult<Value> {
875 if let Some(entry) = global().sources.get(kind) {
876 return Ok((entry.schema)());
877 }
878 match kind {
879 #[cfg(feature = "source-rest")]
880 "rest" => Ok(schema::<faucet_source_rest::RestStreamConfig>()),
881 #[cfg(feature = "source-graphql")]
882 "graphql" => Ok(schema::<faucet_source_graphql::GraphqlStreamConfig>()),
883 #[cfg(feature = "source-xml")]
884 "xml" => Ok(schema::<faucet_source_xml::XmlStreamConfig>()),
885 #[cfg(feature = "source-grpc")]
886 "grpc" => Ok(schema::<faucet_source_grpc::GrpcStreamConfig>()),
887 #[cfg(feature = "source-postgres")]
888 "postgres" => Ok(schema::<faucet_source_postgres::PostgresSourceConfig>()),
889 #[cfg(feature = "source-postgres-cdc")]
890 "postgres-cdc" => Ok(schema::<faucet_source_postgres_cdc::PostgresCdcSourceConfig>()),
891 #[cfg(feature = "source-mysql")]
892 "mysql" => Ok(schema::<faucet_source_mysql::MysqlSourceConfig>()),
893 #[cfg(feature = "source-mssql")]
894 "mssql" => Ok(schema::<faucet_source_mssql::MssqlSourceConfig>()),
895 #[cfg(feature = "source-sqlite")]
896 "sqlite" => Ok(schema::<faucet_source_sqlite::SqliteSourceConfig>()),
897 #[cfg(feature = "source-s3")]
898 "s3" => Ok(schema::<faucet_source_s3::S3SourceConfig>()),
899 #[cfg(feature = "source-mongodb")]
900 "mongodb" => Ok(schema::<faucet_source_mongodb::MongoSourceConfig>()),
901 #[cfg(feature = "source-mongodb-cdc")]
902 "mongodb-cdc" => Ok(schema::<faucet_source_mongodb_cdc::MongoCdcSourceConfig>()),
903 #[cfg(feature = "source-mysql-cdc")]
904 "mysql-cdc" => Ok(schema::<faucet_source_mysql_cdc::MysqlCdcSourceConfig>()),
905 #[cfg(feature = "source-redis")]
906 "redis" => Ok(schema::<faucet_source_redis::RedisSourceConfig>()),
907 #[cfg(feature = "source-webhook")]
908 "webhook" => Ok(schema::<faucet_source_webhook::WebhookSourceConfig>()),
909 #[cfg(feature = "source-websocket")]
910 "websocket" => Ok(schema::<faucet_source_websocket::WebsocketSourceConfig>()),
911 #[cfg(feature = "source-csv")]
912 "csv" => Ok(schema::<faucet_source_csv::CsvSourceConfig>()),
913 #[cfg(feature = "source-singer")]
914 "singer" => Ok(schema::<faucet_source_singer::SingerSourceConfig>()),
915 #[cfg(feature = "source-elasticsearch")]
916 "elasticsearch" => Ok(schema::<
917 faucet_source_elasticsearch::ElasticsearchSourceConfig,
918 >()),
919 #[cfg(feature = "source-kafka")]
920 "kafka" => Ok(schema::<faucet_source_kafka::KafkaSourceConfig>()),
921 #[cfg(feature = "source-kinesis")]
922 "kinesis" => Ok(schema::<faucet_source_kinesis::KinesisSourceConfig>()),
923 #[cfg(feature = "source-spanner")]
924 "spanner" => Ok(schema::<faucet_source_spanner::SpannerSourceConfig>()),
925 #[cfg(feature = "source-parquet")]
926 "parquet" => Ok(schema::<faucet_source_parquet::ParquetSourceConfig>()),
927 #[cfg(feature = "source-delta")]
928 "delta" => Ok(schema::<faucet_source_delta::DeltaSourceConfig>()),
929 #[cfg(feature = "source-databricks")]
930 "databricks" => Ok(schema::<faucet_source_databricks::DatabricksSourceConfig>()),
931 #[cfg(feature = "source-gcs")]
932 "gcs" => Ok(schema::<faucet_source_gcs::GcsSourceConfig>()),
933 #[cfg(feature = "source-bigquery")]
934 "bigquery" => Ok(schema::<faucet_source_bigquery::BigQuerySourceConfig>()),
935 #[cfg(feature = "source-snowflake")]
936 "snowflake" => Ok(schema::<faucet_source_snowflake::SnowflakeSourceConfig>()),
937 #[cfg(feature = "source-mssql-cdc")]
938 "mssql-cdc" => Ok(schema::<faucet_source_mssql_cdc::MssqlCdcSourceConfig>()),
939 #[cfg(feature = "source-redshift")]
940 "redshift" => Ok(schema::<faucet_source_redshift::RedshiftSourceConfig>()),
941 #[cfg(feature = "source-pubsub")]
942 "pubsub" => Ok(schema::<faucet_source_pubsub::PubsubSourceConfig>()),
943 #[cfg(feature = "source-clickhouse")]
944 "clickhouse" => Ok(schema::<faucet_source_clickhouse::ClickHouseSourceConfig>()),
945 #[cfg(feature = "source-azure-blob")]
946 "azure-blob" => Ok(schema::<faucet_source_azure_blob::AzureBlobSourceConfig>()),
947 other => Err(unknown(other, "source", source_kinds())),
948 }
949}
950
951pub fn source_exists(kind: &str) -> bool {
953 source_schema(kind).is_ok()
954}
955
956pub fn sink_exists(kind: &str) -> bool {
958 sink_schema(kind).is_ok()
959}
960
961pub fn sink_schema(kind: &str) -> CliResult<Value> {
963 if let Some(entry) = global().sinks.get(kind) {
964 return Ok((entry.schema)());
965 }
966 match kind {
967 #[cfg(feature = "sink-bigquery")]
968 "bigquery" => Ok(schema::<faucet_sink_bigquery::BigQuerySinkConfig>()),
969 #[cfg(feature = "sink-iceberg")]
970 "iceberg" => Ok(schema::<faucet_sink_iceberg::IcebergSinkConfig>()),
971 #[cfg(feature = "sink-delta")]
972 "delta" => Ok(schema::<faucet_sink_delta::DeltaSinkConfig>()),
973 #[cfg(feature = "sink-postgres")]
974 "postgres" => Ok(schema::<faucet_sink_postgres::PostgresSinkConfig>()),
975 #[cfg(feature = "sink-jsonl")]
976 "jsonl" => Ok(schema::<faucet_sink_jsonl::JsonlSinkConfig>()),
977 #[cfg(feature = "sink-snowflake")]
978 "snowflake" => Ok(schema::<faucet_sink_snowflake::SnowflakeSinkConfig>()),
979 #[cfg(feature = "sink-mysql")]
980 "mysql" => Ok(schema::<faucet_sink_mysql::MysqlSinkConfig>()),
981 #[cfg(feature = "sink-mssql")]
982 "mssql" => Ok(schema::<faucet_sink_mssql::MssqlSinkConfig>()),
983 #[cfg(feature = "sink-sqlite")]
984 "sqlite" => Ok(schema::<faucet_sink_sqlite::SqliteSinkConfig>()),
985 #[cfg(feature = "sink-s3")]
986 "s3" => Ok(schema::<faucet_sink_s3::S3SinkConfig>()),
987 #[cfg(feature = "sink-mongodb")]
988 "mongodb" => Ok(schema::<faucet_sink_mongodb::MongoSinkConfig>()),
989 #[cfg(feature = "sink-redis")]
990 "redis" => Ok(schema::<faucet_sink_redis::RedisSinkConfig>()),
991 #[cfg(feature = "sink-csv")]
992 "csv" => Ok(schema::<faucet_sink_csv::CsvSinkConfig>()),
993 #[cfg(feature = "sink-elasticsearch")]
994 "elasticsearch" => Ok(schema::<faucet_sink_elasticsearch::ElasticsearchSinkConfig>()),
995 #[cfg(feature = "sink-kafka")]
996 "kafka" => Ok(schema::<faucet_sink_kafka::KafkaSinkConfig>()),
997 #[cfg(feature = "sink-kinesis")]
998 "kinesis" => Ok(schema::<faucet_sink_kinesis::KinesisSinkConfig>()),
999 #[cfg(feature = "sink-spanner")]
1000 "spanner" => Ok(schema::<faucet_sink_spanner::SpannerSinkConfig>()),
1001 #[cfg(feature = "sink-http")]
1002 "http" => Ok(schema::<faucet_sink_http::HttpSinkConfig>()),
1003 #[cfg(feature = "sink-stdout")]
1004 "stdout" => Ok(schema::<faucet_sink_stdout::StdoutSinkConfig>()),
1005 #[cfg(feature = "sink-parquet")]
1006 "parquet" => Ok(schema::<faucet_sink_parquet::ParquetSinkConfig>()),
1007 #[cfg(feature = "sink-gcs")]
1008 "gcs" => Ok(schema::<faucet_sink_gcs::GcsSinkConfig>()),
1009 #[cfg(feature = "sink-redshift")]
1010 "redshift" => Ok(schema::<faucet_sink_redshift::RedshiftSinkConfig>()),
1011 #[cfg(feature = "sink-pubsub")]
1012 "pubsub" => Ok(schema::<faucet_sink_pubsub::PubsubSinkConfig>()),
1013 #[cfg(feature = "sink-clickhouse")]
1014 "clickhouse" => Ok(schema::<faucet_sink_clickhouse::ClickHouseSinkConfig>()),
1015 #[cfg(feature = "sink-azure-blob")]
1016 "azure-blob" => Ok(schema::<faucet_sink_azure_blob::AzureBlobSinkConfig>()),
1017 other => Err(unknown(other, "sink", sink_kinds())),
1018 }
1019}
1020
1021pub fn source_descriptions() -> Vec<(&'static str, &'static str)> {
1025 let mut v = builtin_source_descriptions();
1026 v.extend(global().custom_source_descriptions());
1027 v
1028}
1029
1030#[allow(clippy::vec_init_then_push)]
1032fn builtin_source_descriptions() -> Vec<(&'static str, &'static str)> {
1033 let mut v: Vec<(&'static str, &'static str)> = Vec::new();
1034 #[cfg(feature = "source-rest")]
1035 v.push(("rest", "REST API source with pagination, auth, transforms"));
1036 #[cfg(feature = "source-graphql")]
1037 v.push(("graphql", "GraphQL API source with cursor pagination"));
1038 #[cfg(feature = "source-xml")]
1039 v.push(("xml", "XML / SOAP API source with XML→JSON conversion"));
1040 #[cfg(feature = "source-grpc")]
1041 v.push(("grpc", "gRPC source with dynamic protobuf"));
1042 #[cfg(feature = "source-postgres")]
1043 v.push(("postgres", "PostgreSQL query source"));
1044 #[cfg(feature = "source-postgres-cdc")]
1045 v.push((
1046 "postgres-cdc",
1047 "PostgreSQL CDC source (logical replication)",
1048 ));
1049 #[cfg(feature = "source-mysql")]
1050 v.push(("mysql", "MySQL query source"));
1051 #[cfg(feature = "source-mssql")]
1052 v.push(("mssql", "Microsoft SQL Server query source"));
1053 #[cfg(feature = "source-sqlite")]
1054 v.push(("sqlite", "SQLite query source"));
1055 #[cfg(feature = "source-s3")]
1056 v.push(("s3", "AWS S3 object source"));
1057 #[cfg(feature = "source-mongodb")]
1058 v.push(("mongodb", "MongoDB query source"));
1059 #[cfg(feature = "source-mongodb-cdc")]
1060 v.push(("mongodb-cdc", "MongoDB CDC source (Change Streams)"));
1061 #[cfg(feature = "source-mysql-cdc")]
1062 v.push(("mysql-cdc", "MySQL CDC source (binlog replication)"));
1063 #[cfg(feature = "source-mssql-cdc")]
1064 v.push((
1065 "mssql-cdc",
1066 "Microsoft SQL Server CDC source (change data capture, exactly-once capable)",
1067 ));
1068 #[cfg(feature = "source-redshift")]
1069 v.push((
1070 "redshift",
1071 "Amazon Redshift query source (PostgreSQL wire; streaming rows, incremental replication)",
1072 ));
1073 #[cfg(feature = "source-pubsub")]
1074 v.push((
1075 "pubsub",
1076 "Google Cloud Pub/Sub consumer — streaming pull with per-message records, attribute mapping, and ack at durable page boundaries (at-least-once)",
1077 ));
1078 #[cfg(feature = "source-clickhouse")]
1079 v.push((
1080 "clickhouse",
1081 "ClickHouse query source (HTTP interface, JSONEachRow streaming)",
1082 ));
1083 #[cfg(feature = "source-azure-blob")]
1084 v.push((
1085 "azure-blob",
1086 "Azure Blob Storage / ADLS Gen2 source — JSONL, JSON array, or raw text",
1087 ));
1088 #[cfg(feature = "source-redis")]
1089 v.push(("redis", "Redis (streams, lists, keys) source"));
1090 #[cfg(feature = "source-webhook")]
1091 v.push(("webhook", "Webhook HTTP receiver source"));
1092 #[cfg(feature = "source-websocket")]
1093 v.push((
1094 "websocket",
1095 "WebSocket streaming source — connects, subscribes, streams each message as a record",
1096 ));
1097 #[cfg(feature = "source-csv")]
1098 v.push(("csv", "CSV file source"));
1099 #[cfg(feature = "source-singer")]
1100 v.push((
1101 "singer",
1102 "Singer tap bridge (runs an external Singer tap; single-stream v0, Tier-2/experimental)",
1103 ));
1104 #[cfg(feature = "source-elasticsearch")]
1105 v.push(("elasticsearch", "Elasticsearch search / scroll source"));
1106 #[cfg(feature = "source-kafka")]
1107 v.push(("kafka", "Apache Kafka consumer (rdkafka). Subscribes to topics and drains messages with idle/max-messages termination."));
1108 #[cfg(feature = "source-kinesis")]
1109 v.push(("kinesis", "AWS Kinesis Data Streams consumer. Per-shard workers with resumable sequence-number checkpoints and idle/max-messages termination."));
1110 #[cfg(feature = "source-spanner")]
1111 v.push(("spanner", "Google Cloud Spanner query source. Streaming SQL reads with incremental replication bookmarks, stale reads, and PK-range sharding."));
1112 #[cfg(feature = "source-parquet")]
1113 v.push(("parquet", "Apache Parquet file source (local path, glob, or S3). Streams record batches via the Arrow async reader."));
1114 #[cfg(feature = "source-delta")]
1115 v.push(("delta", "Apache Delta Lake source (local FS or S3/Azure/GCS). Streams active data files with time travel and projection pushdown."));
1116 #[cfg(feature = "source-databricks")]
1117 v.push(("databricks", "Databricks SQL query source (Statement Execution API). Streams typed query results with chunk pagination and incremental replication."));
1118 #[cfg(feature = "source-gcs")]
1119 v.push((
1120 "gcs",
1121 "Google Cloud Storage source — JSONL, JSON array, or raw text",
1122 ));
1123 #[cfg(feature = "source-bigquery")]
1124 v.push((
1125 "bigquery",
1126 "Google BigQuery query source (jobs.query + jobs.getQueryResults)",
1127 ));
1128 #[cfg(feature = "source-snowflake")]
1129 v.push((
1130 "snowflake",
1131 "Snowflake query source (SQL REST API with partition paging)",
1132 ));
1133 v
1134}
1135
1136pub fn sink_descriptions() -> Vec<(&'static str, &'static str)> {
1140 let mut v = builtin_sink_descriptions();
1141 v.extend(global().custom_sink_descriptions());
1142 v
1143}
1144
1145#[allow(clippy::vec_init_then_push)]
1147fn builtin_sink_descriptions() -> Vec<(&'static str, &'static str)> {
1148 let mut v: Vec<(&'static str, &'static str)> = Vec::new();
1149 #[cfg(feature = "sink-bigquery")]
1150 v.push(("bigquery", "Google BigQuery streaming-insert sink"));
1151 #[cfg(feature = "sink-iceberg")]
1152 v.push((
1153 "iceberg",
1154 "Apache Iceberg sink (append, REST/Glue/SQL/HMS catalogs)",
1155 ));
1156 #[cfg(feature = "sink-postgres")]
1157 v.push(("postgres", "PostgreSQL sink (JSONB or auto-mapped columns)"));
1158 #[cfg(feature = "sink-jsonl")]
1159 v.push(("jsonl", "JSON Lines file sink"));
1160 #[cfg(feature = "sink-snowflake")]
1161 v.push(("snowflake", "Snowflake SQL REST API sink"));
1162 #[cfg(feature = "sink-mysql")]
1163 v.push(("mysql", "MySQL sink"));
1164 #[cfg(feature = "sink-mssql")]
1165 v.push((
1166 "mssql",
1167 "Microsoft SQL Server sink (auto-mapped columns or JSON column)",
1168 ));
1169 #[cfg(feature = "sink-sqlite")]
1170 v.push(("sqlite", "SQLite sink"));
1171 #[cfg(feature = "sink-s3")]
1172 v.push(("s3", "AWS S3 object sink"));
1173 #[cfg(feature = "sink-mongodb")]
1174 v.push(("mongodb", "MongoDB insert sink"));
1175 #[cfg(feature = "sink-redis")]
1176 v.push(("redis", "Redis (streams, lists, key-value) sink"));
1177 #[cfg(feature = "sink-csv")]
1178 v.push(("csv", "CSV file sink"));
1179 #[cfg(feature = "sink-elasticsearch")]
1180 v.push(("elasticsearch", "Elasticsearch bulk index sink"));
1181 #[cfg(feature = "sink-kafka")]
1182 v.push(("kafka", "Apache Kafka producer (rdkafka). FuturesUnordered batched sends with QueueFull retry; supports fixed or per-record topic routing."));
1183 #[cfg(feature = "sink-kinesis")]
1184 v.push(("kinesis", "AWS Kinesis Data Streams producer. Batched PutRecords with partition-key routing and partial-failure retry (DLQ-routable)."));
1185 #[cfg(feature = "sink-spanner")]
1186 v.push(("spanner", "Google Cloud Spanner sink. Batched mutations with upsert/delete write modes, exactly-once commit tokens, and schema evolution."));
1187 #[cfg(feature = "sink-http")]
1188 v.push(("http", "HTTP POST sink (individual or array batch)"));
1189 #[cfg(feature = "sink-stdout")]
1190 v.push(("stdout", "Stdout / stderr sink (JSON Lines, pretty, TSV)"));
1191 #[cfg(feature = "sink-parquet")]
1192 v.push(("parquet", "Apache Parquet file sink (local path or S3). Schema-inferred, configurable compression, row/byte rollover."));
1193 #[cfg(feature = "sink-delta")]
1194 v.push(("delta", "Apache Delta Lake sink (local FS or S3/Azure/GCS). Append-only, schema-inferred table creation, one commit per flush."));
1195 #[cfg(feature = "sink-gcs")]
1196 v.push(("gcs", "Google Cloud Storage sink — JSONL files"));
1197 #[cfg(feature = "sink-redshift")]
1198 v.push((
1199 "redshift",
1200 "Amazon Redshift sink (COPY-from-S3 or multi-row INSERT)",
1201 ));
1202 #[cfg(feature = "sink-pubsub")]
1203 v.push((
1204 "pubsub",
1205 "Google Cloud Pub/Sub producer — batched publish with optional ordering keys, bounded concurrency, and partial-failure retry (DLQ-routable)",
1206 ));
1207 #[cfg(feature = "sink-clickhouse")]
1208 v.push((
1209 "clickhouse",
1210 "ClickHouse sink (HTTP INSERT … FORMAT JSONEachRow; optional async inserts)",
1211 ));
1212 #[cfg(feature = "sink-azure-blob")]
1213 v.push((
1214 "azure-blob",
1215 "Azure Blob Storage / ADLS Gen2 sink — JSONL files",
1216 ));
1217 v
1218}
1219
1220pub fn source_kinds() -> Vec<&'static str> {
1222 source_descriptions().into_iter().map(|(k, _)| k).collect()
1223}
1224
1225pub fn sink_kinds() -> Vec<&'static str> {
1227 sink_descriptions().into_iter().map(|(k, _)| k).collect()
1228}
1229
1230fn decode<T: DeserializeOwned>(kind: &'static str, name: &str, config: Value) -> CliResult<T> {
1231 serde_json::from_value(config).map_err(|e| CliError::InvalidConnectorConfig {
1232 kind,
1233 name: name.to_owned(),
1234 message: scrub_config_error(&e.to_string()),
1235 })
1236}
1237
1238fn scrub_config_error(msg: &str) -> String {
1248 const MAX_CHARS: usize = 200;
1249 let mut out = String::with_capacity(msg.len());
1250 let mut in_quote = false;
1251 for c in msg.chars() {
1252 if c == '"' {
1253 if !in_quote {
1254 out.push_str("\"<redacted>\"");
1255 }
1256 in_quote = !in_quote;
1257 continue;
1258 }
1259 if !in_quote {
1260 out.push(c);
1261 }
1262 }
1263 if out.chars().count() > MAX_CHARS {
1264 let truncated: String = out.chars().take(MAX_CHARS).collect();
1265 return format!("{truncated}…");
1266 }
1267 out
1268}
1269
1270fn schema<T: faucet_core::JsonSchema>() -> Value {
1271 serde_json::to_value(faucet_core::schema_for!(T))
1272 .unwrap_or_else(|_| serde_json::json!({"type": "object"}))
1273}
1274
1275fn unknown(name: &str, kind: &'static str, available: Vec<&'static str>) -> CliError {
1276 CliError::UnknownConnector {
1277 kind,
1278 name: name.to_owned(),
1279 available: if available.is_empty() {
1280 "(none — rebuild faucet-cli with the relevant feature enabled)".to_owned()
1281 } else {
1282 available.join(", ")
1283 },
1284 }
1285}
1286
1287#[cfg(test)]
1288mod tests {
1289 use super::*;
1290
1291 #[derive(Clone)]
1294 struct DummySource;
1295 #[faucet_core::async_trait]
1296 impl Source for DummySource {
1297 async fn fetch_with_context(
1298 &self,
1299 _ctx: &std::collections::HashMap<String, Value>,
1300 ) -> Result<Vec<Value>, faucet_core::FaucetError> {
1301 Ok(vec![serde_json::json!({"ok": true})])
1302 }
1303 fn config_schema(&self) -> Value {
1304 serde_json::json!({"type": "object"})
1305 }
1306 }
1307
1308 #[test]
1309 fn register_source_rejects_builtin_collision() {
1310 let reg = PluginRegistry::with_builtins()
1313 .register_source("csv", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1314 let err = reg
1317 .install()
1318 .expect_err("built-in collision must be rejected");
1319 match err {
1320 CliError::Config(msg) => assert!(msg.contains("built-in source"), "{msg}"),
1321 other => panic!("expected Config error, got {other:?}"),
1322 }
1323 }
1324
1325 #[test]
1326 fn register_source_rejects_duplicate() {
1327 let reg = PluginRegistry::new()
1328 .register_source("dup", |_| Ok(Box::new(DummySource) as Box<dyn Source>))
1329 .register_source("dup", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1330 let err = reg
1331 .install()
1332 .expect_err("duplicate registration must be rejected");
1333 match err {
1334 CliError::Config(msg) => assert!(msg.contains("more than once"), "{msg}"),
1335 other => panic!("expected Config error, got {other:?}"),
1336 }
1337 }
1338
1339 #[test]
1340 fn register_sink_rejects_duplicate() {
1341 let reg = PluginRegistry::new()
1345 .register_sink("dupsink", |_| Err(CliError::Config("unused".into())))
1346 .register_sink("dupsink", |_| Err(CliError::Config("unused".into())));
1347 assert!(
1348 reg.errors.iter().any(|e| e.contains("more than once")),
1349 "{:?}",
1350 reg.errors
1351 );
1352 }
1353
1354 #[test]
1355 fn custom_descriptions_use_default_when_blank() {
1356 let reg = PluginRegistry::new()
1357 .register_source("acme", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1358 let descs = reg.custom_source_descriptions();
1359 assert_eq!(descs.len(), 1);
1360 assert_eq!(descs[0].0, "acme");
1361 assert_eq!(descs[0].1, "custom source connector");
1362 }
1363
1364 #[test]
1365 fn custom_descriptions_carry_explicit_summary() {
1366 let reg = PluginRegistry::new().register_source_with(
1367 "acme",
1368 |_| Ok(Box::new(DummySource) as Box<dyn Source>),
1369 || serde_json::json!({"type": "object", "title": "acme"}),
1370 "Acme widget source",
1371 );
1372 let descs = reg.custom_source_descriptions();
1373 assert_eq!(descs[0], ("acme", "Acme widget source"));
1374 assert_eq!(
1376 (reg.sources.get("acme").unwrap().schema)()["title"],
1377 serde_json::json!("acme")
1378 );
1379 }
1380
1381 #[test]
1382 fn capability_constants_match_their_predicates() {
1383 for &k in EXACTLY_ONCE_SOURCE_KINDS {
1389 assert!(
1390 source_supports_exactly_once(k),
1391 "{k} should be exactly-once"
1392 );
1393 }
1394 for &k in IDEMPOTENT_SINK_KINDS {
1395 assert!(
1396 sink_supports_idempotent_writes(k),
1397 "{k} should be idempotent"
1398 );
1399 }
1400 for &k in UPSERT_SINK_KINDS {
1401 use faucet_core::WriteMode;
1402 assert!(
1403 sink_supported_write_modes(k).contains(&WriteMode::Upsert),
1404 "{k} should support upsert"
1405 );
1406 }
1407 assert!(IDEMPOTENT_SINK_KINDS.contains(&"bigquery"));
1408 assert!(IDEMPOTENT_SINK_KINDS.contains(&"kafka"));
1409 assert!(UPSERT_SINK_KINDS.contains(&"bigquery"));
1410 }
1411
1412 #[cfg(feature = "source-rest")]
1413 #[test]
1414 fn rest_source_appears_in_listings() {
1415 assert!(source_kinds().contains(&"rest"));
1416 }
1417
1418 #[cfg(feature = "sink-jsonl")]
1419 #[test]
1420 fn jsonl_sink_appears_in_listings() {
1421 assert!(sink_kinds().contains(&"jsonl"));
1422 }
1423
1424 #[tokio::test]
1425 async fn unknown_source_kind_errors() {
1426 let err = build_source("nope", serde_json::json!({}), &AuthCatalog::new(), None)
1427 .await
1428 .err()
1429 .expect("should fail");
1430 match err {
1431 CliError::UnknownConnector { kind, name, .. } => {
1432 assert_eq!(kind, "source");
1433 assert_eq!(name, "nope");
1434 }
1435 other => panic!("expected UnknownConnector, got {other:?}"),
1436 }
1437 }
1438
1439 #[tokio::test]
1440 async fn unknown_sink_kind_errors() {
1441 let err = build_sink("nope", serde_json::json!({}), &AuthCatalog::new())
1442 .await
1443 .err()
1444 .expect("should fail");
1445 assert!(matches!(
1446 err,
1447 CliError::UnknownConnector { kind: "sink", .. }
1448 ));
1449 }
1450
1451 #[cfg(feature = "source-rest")]
1452 #[test]
1453 fn rest_schema_is_object() {
1454 let s = source_schema("rest").unwrap();
1455 assert!(s.is_object());
1456 }
1457
1458 #[cfg(feature = "sink-jsonl")]
1459 #[test]
1460 fn jsonl_schema_is_object() {
1461 let s = sink_schema("jsonl").unwrap();
1462 assert!(s.is_object());
1463 }
1464
1465 #[test]
1466 fn scrub_config_error_redacts_quoted_values() {
1467 let msg =
1470 r#"invalid type: string "sk-super-secret-123", expected a sequence at line 1 column 9"#;
1471 let scrubbed = scrub_config_error(msg);
1472 assert!(!scrubbed.contains("sk-super-secret-123"), "{scrubbed}");
1473 assert!(scrubbed.contains("<redacted>"), "{scrubbed}");
1474 assert!(scrubbed.contains("invalid type"), "{scrubbed}");
1476 assert!(scrubbed.contains("expected a sequence"), "{scrubbed}");
1477 }
1478
1479 #[test]
1480 fn scrub_config_error_truncates_long_messages() {
1481 let msg = "x".repeat(500);
1482 let scrubbed = scrub_config_error(&msg);
1483 assert!(
1484 scrubbed.chars().count() <= 201,
1485 "len {}",
1486 scrubbed.chars().count()
1487 );
1488 assert!(scrubbed.ends_with('…'));
1489 }
1490
1491 #[cfg(feature = "source-csv")]
1495 #[tokio::test]
1496 async fn build_source_csv_succeeds_without_io() {
1497 let src = build_source(
1498 "csv",
1499 serde_json::json!({ "path": "/tmp/does-not-need-to-exist.csv" }),
1500 &AuthCatalog::new(),
1501 None,
1502 )
1503 .await
1504 .expect("csv source should build without I/O");
1505 assert_eq!(src.connector_name(), "CsvSource");
1508 }
1509
1510 #[cfg(feature = "sink-jsonl")]
1513 #[tokio::test]
1514 async fn build_sink_jsonl_succeeds_without_io() {
1515 let sink = build_sink(
1516 "jsonl",
1517 serde_json::json!({ "path": "/tmp/does-not-need-to-exist.jsonl" }),
1518 &AuthCatalog::new(),
1519 )
1520 .await
1521 .expect("jsonl sink should build without I/O");
1522 assert_eq!(sink.connector_name(), "jsonl");
1523 }
1524
1525 #[cfg(feature = "sink-stdout")]
1527 #[tokio::test]
1528 async fn build_sink_stdout_succeeds_without_io() {
1529 let sink = build_sink("stdout", serde_json::json!({}), &AuthCatalog::new())
1530 .await
1531 .expect("stdout sink should build without I/O");
1532 assert_eq!(sink.connector_name(), "StdoutSink");
1535 }
1536
1537 #[cfg(all(feature = "source-delta", feature = "sink-delta"))]
1541 #[tokio::test]
1542 async fn delta_registry_round_trip() {
1543 let dir = tempfile::tempdir().unwrap();
1544 let uri = dir.path().join("reg_delta").to_string_lossy().into_owned();
1545
1546 assert!(source_schema("delta").is_ok());
1547 assert!(sink_schema("delta").is_ok());
1548 assert!(source_descriptions().iter().any(|(n, _)| *n == "delta"));
1549 assert!(sink_descriptions().iter().any(|(n, _)| *n == "delta"));
1550
1551 let sink = build_sink(
1552 "delta",
1553 serde_json::json!({ "table_uri": uri }),
1554 &AuthCatalog::new(),
1555 )
1556 .await
1557 .expect("delta sink builds");
1558 assert_eq!(sink.connector_name(), "delta");
1559 let n = sink
1560 .write_batch(&[serde_json::json!({"id": 1}), serde_json::json!({"id": 2})])
1561 .await
1562 .expect("write");
1563 assert_eq!(n, 2);
1564 sink.flush().await.expect("flush");
1565
1566 let source = build_source(
1567 "delta",
1568 serde_json::json!({ "table_uri": uri }),
1569 &AuthCatalog::new(),
1570 None,
1571 )
1572 .await
1573 .expect("delta source builds");
1574 assert_eq!(source.connector_name(), "delta");
1575 let rows = source
1576 .fetch_with_context(&std::collections::HashMap::new())
1577 .await
1578 .expect("read");
1579 assert_eq!(rows.len(), 2);
1580 }
1581
1582 #[cfg(feature = "source-databricks")]
1585 #[tokio::test]
1586 async fn databricks_registry_source_builds() {
1587 assert!(source_schema("databricks").is_ok());
1588 assert!(
1589 source_descriptions()
1590 .iter()
1591 .any(|(n, _)| *n == "databricks")
1592 );
1593 let cfg = serde_json::json!({
1594 "workspace_url": "https://x.cloud.databricks.com",
1595 "warehouse_id": "wh1",
1596 "sql": "SELECT 1",
1597 "auth": { "type": "pat", "config": { "token": "t" } }
1598 });
1599 let src = build_source("databricks", cfg, &AuthCatalog::new(), None)
1600 .await
1601 .expect("databricks source builds");
1602 assert_eq!(src.connector_name(), "databricks");
1603 }
1604
1605 #[cfg(feature = "source-csv")]
1608 #[tokio::test]
1609 async fn build_source_csv_invalid_config_errors() {
1610 let res = build_source(
1614 "csv",
1615 serde_json::json!({ "path": 42 }),
1616 &AuthCatalog::new(),
1617 None,
1618 )
1619 .await;
1620 match res {
1621 Err(CliError::InvalidConnectorConfig { kind, name, .. }) => {
1622 assert_eq!(kind, "source");
1623 assert_eq!(name, "csv");
1624 }
1625 Ok(_) => panic!("expected InvalidConnectorConfig, got Ok"),
1626 Err(other) => panic!("expected InvalidConnectorConfig, got {other:?}"),
1627 }
1628 }
1629
1630 #[cfg(feature = "source-csv")]
1633 #[test]
1634 fn source_schema_csv_exposes_path_property() {
1635 let schema = source_schema("csv").expect("csv schema");
1636 let props = schema
1637 .get("properties")
1638 .and_then(Value::as_object)
1639 .expect("schema should have a properties object");
1640 assert!(props.contains_key("path"), "schema props: {props:?}");
1641 }
1642
1643 #[cfg(feature = "sink-jsonl")]
1644 #[test]
1645 fn sink_schema_jsonl_exposes_path_property() {
1646 let schema = sink_schema("jsonl").expect("jsonl schema");
1647 let props = schema
1648 .get("properties")
1649 .and_then(Value::as_object)
1650 .expect("schema should have a properties object");
1651 assert!(props.contains_key("path"), "schema props: {props:?}");
1652 }
1653
1654 #[test]
1655 fn unknown_source_schema_errors_with_available_list() {
1656 let err = source_schema("definitely-not-a-source").expect_err("unknown source");
1657 match err {
1658 CliError::UnknownConnector {
1659 kind,
1660 name,
1661 available,
1662 } => {
1663 assert_eq!(kind, "source");
1664 assert_eq!(name, "definitely-not-a-source");
1665 assert!(!available.is_empty());
1667 }
1668 other => panic!("expected UnknownConnector, got {other:?}"),
1669 }
1670 }
1671
1672 #[test]
1673 fn unknown_sink_schema_errors() {
1674 let err = sink_schema("definitely-not-a-sink").expect_err("unknown sink");
1675 assert!(matches!(
1676 err,
1677 CliError::UnknownConnector { kind: "sink", .. }
1678 ));
1679 }
1680
1681 #[cfg(feature = "source-csv")]
1682 #[test]
1683 fn source_exists_is_true_for_known_and_false_for_unknown() {
1684 assert!(source_exists("csv"));
1685 assert!(!source_exists("definitely-not-a-source"));
1686 }
1687
1688 #[cfg(feature = "sink-jsonl")]
1689 #[test]
1690 fn sink_exists_is_true_for_known_and_false_for_unknown() {
1691 assert!(sink_exists("jsonl"));
1692 assert!(!sink_exists("definitely-not-a-sink"));
1693 }
1694
1695 #[test]
1698 fn source_descriptions_are_non_empty_and_consistent() {
1699 let descs = source_descriptions();
1700 assert!(!descs.is_empty());
1701 for (name, summary) in &descs {
1702 assert!(!name.is_empty(), "empty connector name");
1703 assert!(!summary.is_empty(), "empty summary for {name}");
1704 assert!(
1705 source_schema(name).is_ok(),
1706 "listed source `{name}` has no schema"
1707 );
1708 }
1709 }
1710
1711 #[test]
1712 fn sink_descriptions_are_non_empty_and_consistent() {
1713 let descs = sink_descriptions();
1714 assert!(!descs.is_empty());
1715 for (name, summary) in &descs {
1716 assert!(!name.is_empty(), "empty connector name");
1717 assert!(!summary.is_empty(), "empty summary for {name}");
1718 assert!(
1719 sink_schema(name).is_ok(),
1720 "listed sink `{name}` has no schema"
1721 );
1722 }
1723 }
1724
1725 #[cfg(all(feature = "source-csv", feature = "source-rest"))]
1728 #[test]
1729 fn source_kinds_contains_expected_builtins() {
1730 let kinds = source_kinds();
1731 assert!(kinds.contains(&"csv"));
1732 assert!(kinds.contains(&"rest"));
1733 }
1734
1735 #[cfg(all(feature = "sink-jsonl", feature = "sink-stdout"))]
1736 #[test]
1737 fn sink_kinds_contains_expected_builtins() {
1738 let kinds = sink_kinds();
1739 assert!(kinds.contains(&"jsonl"));
1740 assert!(kinds.contains(&"stdout"));
1741 }
1742
1743 #[cfg(feature = "source-rest")]
1747 #[tokio::test]
1748 async fn build_source_injects_referenced_auth_provider() {
1749 let mut specs = std::collections::HashMap::new();
1750 specs.insert(
1751 "tok".to_string(),
1752 serde_json::json!({"type": "static", "config": {"token": "abc"}}),
1753 );
1754 let catalog = auth_catalog::build_auth_catalog(Some(&specs)).expect("catalog");
1755
1756 let src = build_source("rest", rest_config_with_auth_ref("tok"), &catalog, None)
1757 .await
1758 .expect("rest source with a resolvable auth ref should build");
1759 assert_eq!(src.connector_name(), "rest");
1760 }
1761
1762 #[cfg(feature = "source-rest")]
1765 fn rest_config_with_auth_ref(name: &str) -> Value {
1766 let cfg = faucet_source_rest::RestStreamConfig::new("https://api.example.com", "/v1");
1767 let mut v = serde_json::to_value(cfg).expect("serialize rest config");
1768 v.as_object_mut()
1769 .unwrap()
1770 .insert("auth".to_string(), serde_json::json!({ "ref": name }));
1771 v
1772 }
1773
1774 #[cfg(feature = "source-rest")]
1777 #[tokio::test]
1778 async fn build_source_unknown_auth_ref_errors() {
1779 let res = build_source(
1780 "rest",
1781 rest_config_with_auth_ref("missing"),
1782 &AuthCatalog::new(),
1783 None,
1784 )
1785 .await;
1786 match res {
1787 Err(CliError::UnknownAuthProvider { name, .. }) => assert_eq!(name, "missing"),
1788 Ok(_) => panic!("expected UnknownAuthProvider, got Ok"),
1789 Err(other) => panic!("expected UnknownAuthProvider, got {other:?}"),
1790 }
1791 }
1792
1793 #[test]
1794 fn exactly_once_capability_allowlists() {
1795 assert!(source_supports_exactly_once("postgres-cdc"));
1796 assert!(source_supports_exactly_once("mysql-cdc"));
1797 assert!(source_supports_exactly_once("mongodb-cdc"));
1798 assert!(source_supports_exactly_once("kafka"));
1799 assert!(!source_supports_exactly_once("rest"));
1800
1801 assert!(sink_supports_idempotent_writes("postgres"));
1802 assert!(sink_supports_idempotent_writes("iceberg"));
1803 assert!(sink_supports_idempotent_writes("bigquery"));
1804 assert!(sink_supports_idempotent_writes("kafka"));
1805 assert!(sink_supports_idempotent_writes("snowflake"));
1806 assert!(sink_supports_idempotent_writes("redis"));
1807 assert!(sink_supports_idempotent_writes("mongodb"));
1808 assert!(!sink_supports_idempotent_writes("jsonl"));
1809 }
1810
1811 #[test]
1812 fn typed_delivery_capabilities_derive_from_kind_tables() {
1813 use faucet_core::{ReplayGuarantee, SinkGuarantee};
1814 assert_eq!(
1815 source_replay_guarantee("kafka"),
1816 ReplayGuarantee::Deterministic
1817 );
1818 assert_eq!(
1819 source_replay_guarantee("rest"),
1820 ReplayGuarantee::NonDeterministic
1821 );
1822 assert_eq!(sink_guarantee("postgres"), SinkGuarantee::AtomicWatermark);
1823 assert_eq!(sink_guarantee("elasticsearch"), SinkGuarantee::KeyedUpsert);
1825 assert_eq!(sink_guarantee("jsonl"), SinkGuarantee::AtLeastOnce);
1826 }
1827
1828 #[test]
1829 fn sink_supported_write_modes_allowlist() {
1830 use faucet_core::WriteMode;
1831 assert!(sink_supported_write_modes("postgres").contains(&WriteMode::Upsert));
1832 assert!(sink_supported_write_modes("elasticsearch").contains(&WriteMode::Delete));
1833 assert!(sink_supported_write_modes("bigquery").contains(&WriteMode::Upsert));
1834 assert_eq!(sink_supported_write_modes("jsonl"), &[WriteMode::Append]);
1836 assert_eq!(sink_supported_write_modes("kafka"), &[WriteMode::Append]);
1837 }
1838
1839 #[test]
1840 fn sink_supports_schema_evolution_allowlist() {
1841 assert!(sink_supports_schema_evolution("postgres"));
1842 assert!(sink_supports_schema_evolution("mysql"));
1843 assert!(sink_supports_schema_evolution("mssql"));
1844 assert!(sink_supports_schema_evolution("sqlite"));
1845 assert!(sink_supports_schema_evolution("bigquery"));
1846 assert!(sink_supports_schema_evolution("elasticsearch"));
1847 assert!(!sink_supports_schema_evolution("iceberg"));
1848 assert!(!sink_supports_schema_evolution("jsonl"));
1849 assert!(!sink_supports_schema_evolution("kafka"));
1850 }
1851}