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 cfg.validate()?;
291 let mut s = faucet_source_graphql::GraphqlStream::try_new(cfg)?;
294 if let Some(name) = &auth_ref {
295 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
296 }
297 if let Some(rp) = retry_policy {
298 s = s.with_retry_policy(rp.clone());
299 }
300 Ok(Box::new(s))
301 }
302 #[cfg(feature = "source-xml")]
303 "xml" => {
304 let cfg = decode::<faucet_source_xml::XmlStreamConfig>("source", "xml", config)?;
305 let mut s = faucet_source_xml::XmlStream::try_new(cfg)?;
308 if let Some(name) = &auth_ref {
309 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
310 }
311 if let Some(rp) = retry_policy {
312 s = s.with_retry_policy(rp.clone());
313 }
314 Ok(Box::new(s))
315 }
316 #[cfg(feature = "source-grpc")]
317 "grpc" => {
318 let cfg = decode::<faucet_source_grpc::GrpcStreamConfig>("source", "grpc", config)?;
319 let mut s = faucet_source_grpc::GrpcStream::new(cfg)?;
320 if let Some(name) = &auth_ref {
321 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
322 }
323 Ok(Box::new(s))
324 }
325 #[cfg(feature = "source-postgres")]
326 "postgres" => {
327 let cfg = decode::<faucet_source_postgres::PostgresSourceConfig>(
328 "source", "postgres", config,
329 )?;
330 Ok(Box::new(
331 faucet_source_postgres::PostgresSource::new(cfg).await?,
332 ))
333 }
334 #[cfg(feature = "source-postgres-cdc")]
335 "postgres-cdc" => {
336 let cfg = decode::<faucet_source_postgres_cdc::PostgresCdcSourceConfig>(
337 "source",
338 "postgres-cdc",
339 config,
340 )?;
341 Ok(Box::new(
342 faucet_source_postgres_cdc::PostgresCdcSource::new(cfg).await?,
343 ))
344 }
345 #[cfg(feature = "source-mysql")]
346 "mysql" => {
347 let cfg = decode::<faucet_source_mysql::MysqlSourceConfig>("source", "mysql", config)?;
348 Ok(Box::new(faucet_source_mysql::MysqlSource::new(cfg).await?))
349 }
350 #[cfg(feature = "source-mssql")]
351 "mssql" => {
352 let cfg = decode::<faucet_source_mssql::MssqlSourceConfig>("source", "mssql", config)?;
353 Ok(Box::new(faucet_source_mssql::MssqlSource::new(cfg).await?))
354 }
355 #[cfg(feature = "source-sqlite")]
356 "sqlite" => {
357 let cfg =
358 decode::<faucet_source_sqlite::SqliteSourceConfig>("source", "sqlite", config)?;
359 Ok(Box::new(
360 faucet_source_sqlite::SqliteSource::new(cfg).await?,
361 ))
362 }
363 #[cfg(feature = "source-duckdb")]
364 "duckdb" => {
365 let cfg =
366 decode::<faucet_source_duckdb::DuckdbSourceConfig>("source", "duckdb", config)?;
367 Ok(Box::new(
368 faucet_source_duckdb::DuckdbSource::new(cfg).await?,
369 ))
370 }
371 #[cfg(feature = "source-sqs")]
372 "sqs" => {
373 let cfg = decode::<faucet_source_sqs::SqsSourceConfig>("source", "sqs", config)?;
374 Ok(Box::new(faucet_source_sqs::SqsSource::new(cfg).await?))
375 }
376 #[cfg(feature = "source-nats")]
377 "nats" => {
378 let cfg = decode::<faucet_source_nats::NatsSourceConfig>("source", "nats", config)?;
379 Ok(Box::new(faucet_source_nats::NatsSource::new(cfg).await?))
380 }
381 #[cfg(feature = "source-sftp")]
382 "sftp" => {
383 let cfg = decode::<faucet_source_sftp::SftpSourceConfig>("source", "sftp", config)?;
384 Ok(Box::new(faucet_source_sftp::SftpSource::new(cfg)?))
385 }
386 #[cfg(feature = "source-s3")]
387 "s3" => {
388 let cfg = decode::<faucet_source_s3::S3SourceConfig>("source", "s3", config)?;
389 Ok(Box::new(faucet_source_s3::S3Source::new(cfg).await?))
390 }
391 #[cfg(feature = "source-mongodb")]
392 "mongodb" => {
393 let cfg =
394 decode::<faucet_source_mongodb::MongoSourceConfig>("source", "mongodb", config)?;
395 Ok(Box::new(
396 faucet_source_mongodb::MongoSource::new(cfg).await?,
397 ))
398 }
399 #[cfg(feature = "source-mongodb-cdc")]
400 "mongodb-cdc" => {
401 let cfg = decode::<faucet_source_mongodb_cdc::MongoCdcSourceConfig>(
402 "source",
403 "mongodb-cdc",
404 config,
405 )?;
406 Ok(Box::new(
407 faucet_source_mongodb_cdc::MongoCdcSource::new(cfg).await?,
408 ))
409 }
410 #[cfg(feature = "source-mysql-cdc")]
411 "mysql-cdc" => {
412 let cfg = decode::<faucet_source_mysql_cdc::MysqlCdcSourceConfig>(
413 "source",
414 "mysql-cdc",
415 config,
416 )?;
417 Ok(Box::new(
418 faucet_source_mysql_cdc::MysqlCdcSource::new(cfg).await?,
419 ))
420 }
421 #[cfg(feature = "source-redis")]
422 "redis" => {
423 let cfg = decode::<faucet_source_redis::RedisSourceConfig>("source", "redis", config)?;
424 Ok(Box::new(faucet_source_redis::RedisSource::new(cfg)?))
425 }
426 #[cfg(feature = "source-webhook")]
427 "webhook" => {
428 let cfg =
429 decode::<faucet_source_webhook::WebhookSourceConfig>("source", "webhook", config)?;
430 Ok(Box::new(faucet_source_webhook::WebhookSource::new(cfg)))
431 }
432 #[cfg(feature = "source-websocket")]
433 "websocket" => {
434 let cfg = decode::<faucet_source_websocket::WebsocketSourceConfig>(
435 "source",
436 "websocket",
437 config,
438 )?;
439 let mut s = faucet_source_websocket::WebsocketSource::new(cfg)?;
440 if let Some(name) = &auth_ref {
441 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
442 }
443 Ok(Box::new(s))
444 }
445 #[cfg(feature = "source-csv")]
446 "csv" => {
447 let cfg = decode::<faucet_source_csv::CsvSourceConfig>("source", "csv", config)?;
448 cfg.validate()?;
449 Ok(Box::new(faucet_source_csv::CsvSource::new(cfg)))
450 }
451 #[cfg(feature = "source-singer")]
452 "singer" => {
453 let cfg =
454 decode::<faucet_source_singer::SingerSourceConfig>("source", "singer", config)?;
455 Ok(Box::new(faucet_source_singer::SingerSource::new(cfg)))
456 }
457 #[cfg(feature = "source-elasticsearch")]
458 "elasticsearch" => {
459 let cfg = decode::<faucet_source_elasticsearch::ElasticsearchSourceConfig>(
460 "source",
461 "elasticsearch",
462 config,
463 )?;
464 let mut s = faucet_source_elasticsearch::ElasticsearchSource::new(cfg)?;
465 if let Some(name) = &auth_ref {
466 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
467 }
468 Ok(Box::new(s))
469 }
470 #[cfg(feature = "source-kafka")]
471 "kafka" => {
472 let cfg = decode::<faucet_source_kafka::KafkaSourceConfig>("source", "kafka", config)?;
473 Ok(Box::new(faucet_source_kafka::KafkaSource::new(cfg).await?))
474 }
475 #[cfg(feature = "source-kinesis")]
476 "kinesis" => {
477 let cfg =
478 decode::<faucet_source_kinesis::KinesisSourceConfig>("source", "kinesis", config)?;
479 Ok(Box::new(
480 faucet_source_kinesis::KinesisSource::new(cfg).await?,
481 ))
482 }
483 #[cfg(feature = "source-spanner")]
484 "spanner" => {
485 let cfg =
486 decode::<faucet_source_spanner::SpannerSourceConfig>("source", "spanner", config)?;
487 Ok(Box::new(
488 faucet_source_spanner::SpannerSource::new(cfg).await?,
489 ))
490 }
491 #[cfg(feature = "source-parquet")]
492 "parquet" => {
493 let cfg =
494 decode::<faucet_source_parquet::ParquetSourceConfig>("source", "parquet", config)?;
495 Ok(Box::new(
496 faucet_source_parquet::ParquetSource::new(cfg).await?,
497 ))
498 }
499 #[cfg(feature = "source-delta")]
500 "delta" => {
501 let cfg = decode::<faucet_source_delta::DeltaSourceConfig>("source", "delta", config)?;
502 Ok(Box::new(faucet_source_delta::DeltaSource::new(cfg).await?))
503 }
504 #[cfg(feature = "source-databricks")]
505 "databricks" => {
506 let cfg = decode::<faucet_source_databricks::DatabricksSourceConfig>(
507 "source",
508 "databricks",
509 config,
510 )?;
511 let mut s = faucet_source_databricks::DatabricksSource::new(cfg)?;
512 if let Some(name) = &auth_ref {
513 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
514 }
515 Ok(Box::new(s))
516 }
517 #[cfg(feature = "source-gcs")]
518 "gcs" => {
519 let cfg = decode::<faucet_source_gcs::GcsSourceConfig>("source", "gcs", config)?;
520 Ok(Box::new(faucet_source_gcs::GcsSource::new(cfg).await?))
521 }
522 #[cfg(feature = "source-bigquery")]
523 "bigquery" => {
524 let cfg = decode::<faucet_source_bigquery::BigQuerySourceConfig>(
525 "source", "bigquery", config,
526 )?;
527 Ok(Box::new(
528 faucet_source_bigquery::BigQuerySource::new(cfg).await?,
529 ))
530 }
531 #[cfg(feature = "source-snowflake")]
532 "snowflake" => {
533 let cfg = decode::<faucet_source_snowflake::SnowflakeSourceConfig>(
534 "source",
535 "snowflake",
536 config,
537 )?;
538 let mut s = faucet_source_snowflake::SnowflakeSource::new(cfg)?;
539 if let Some(name) = &auth_ref {
540 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
541 }
542 Ok(Box::new(s))
543 }
544 #[cfg(feature = "source-mssql-cdc")]
545 "mssql-cdc" => {
546 let cfg = decode::<faucet_source_mssql_cdc::MssqlCdcSourceConfig>(
547 "source",
548 "mssql-cdc",
549 config,
550 )?;
551 Ok(Box::new(
552 faucet_source_mssql_cdc::MssqlCdcSource::new(cfg).await?,
553 ))
554 }
555 #[cfg(feature = "source-redshift")]
556 "redshift" => {
557 let cfg = decode::<faucet_source_redshift::RedshiftSourceConfig>(
558 "source", "redshift", config,
559 )?;
560 Ok(Box::new(faucet_source_redshift::RedshiftSource::new(cfg)?))
561 }
562 #[cfg(feature = "source-pubsub")]
563 "pubsub" => {
564 let cfg =
565 decode::<faucet_source_pubsub::PubsubSourceConfig>("source", "pubsub", config)?;
566 Ok(Box::new(
567 faucet_source_pubsub::PubsubSource::new(cfg).await?,
568 ))
569 }
570 #[cfg(feature = "source-clickhouse")]
571 "clickhouse" => {
572 let cfg = decode::<faucet_source_clickhouse::ClickHouseSourceConfig>(
573 "source",
574 "clickhouse",
575 config,
576 )?;
577 Ok(Box::new(faucet_source_clickhouse::ClickHouseSource::new(
578 cfg,
579 )?))
580 }
581 #[cfg(feature = "source-azure-blob")]
582 "azure-blob" => {
583 let cfg = decode::<faucet_source_azure_blob::AzureBlobSourceConfig>(
584 "source",
585 "azure-blob",
586 config,
587 )?;
588 Ok(Box::new(
589 faucet_source_azure_blob::AzureBlobSource::new(cfg).await?,
590 ))
591 }
592 other => Err(unknown(other, "source", source_kinds())),
593 }
594}
595
596pub async fn build_sink(kind: &str, config: Value, auth: &AuthCatalog) -> CliResult<Box<dyn Sink>> {
600 if let Some(entry) = global().sinks.get(kind) {
601 return (entry.factory)(config);
602 }
603 let auth_ref = auth_catalog::auth_ref(&config);
604 match kind {
605 #[cfg(feature = "sink-bigquery")]
606 "bigquery" => {
607 let cfg =
608 decode::<faucet_sink_bigquery::BigQuerySinkConfig>("sink", "bigquery", config)?;
609 Ok(Box::new(
610 faucet_sink_bigquery::BigQuerySink::new(cfg).await?,
611 ))
612 }
613 #[cfg(feature = "sink-iceberg")]
614 "iceberg" => {
615 let cfg = decode::<faucet_sink_iceberg::IcebergSinkConfig>("sink", "iceberg", config)?;
616 Ok(Box::new(faucet_sink_iceberg::IcebergSink::new(cfg).await?))
617 }
618 #[cfg(feature = "sink-delta")]
619 "delta" => {
620 let cfg = decode::<faucet_sink_delta::DeltaSinkConfig>("sink", "delta", config)?;
621 Ok(Box::new(faucet_sink_delta::DeltaSink::new(cfg).await?))
622 }
623 #[cfg(feature = "sink-postgres")]
624 "postgres" => {
625 let cfg =
626 decode::<faucet_sink_postgres::PostgresSinkConfig>("sink", "postgres", config)?;
627 Ok(Box::new(
628 faucet_sink_postgres::PostgresSink::new(cfg).await?,
629 ))
630 }
631 #[cfg(feature = "sink-jsonl")]
632 "jsonl" => {
633 let cfg = decode::<faucet_sink_jsonl::JsonlSinkConfig>("sink", "jsonl", config)?;
634 Ok(Box::new(faucet_sink_jsonl::JsonlSink::new(cfg)))
635 }
636 #[cfg(feature = "sink-snowflake")]
637 "snowflake" => {
638 let cfg =
639 decode::<faucet_sink_snowflake::SnowflakeSinkConfig>("sink", "snowflake", config)?;
640 let mut s = faucet_sink_snowflake::SnowflakeSink::new(cfg)?;
641 if let Some(name) = &auth_ref {
642 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
643 }
644 Ok(Box::new(s))
645 }
646 #[cfg(feature = "sink-mysql")]
647 "mysql" => {
648 let cfg = decode::<faucet_sink_mysql::MysqlSinkConfig>("sink", "mysql", config)?;
649 Ok(Box::new(faucet_sink_mysql::MysqlSink::new(cfg).await?))
650 }
651 #[cfg(feature = "sink-mssql")]
652 "mssql" => {
653 let cfg = decode::<faucet_sink_mssql::MssqlSinkConfig>("sink", "mssql", config)?;
654 Ok(Box::new(faucet_sink_mssql::MssqlSink::new(cfg).await?))
655 }
656 #[cfg(feature = "sink-sqlite")]
657 "sqlite" => {
658 let cfg = decode::<faucet_sink_sqlite::SqliteSinkConfig>("sink", "sqlite", config)?;
659 Ok(Box::new(faucet_sink_sqlite::SqliteSink::new(cfg).await?))
660 }
661 #[cfg(feature = "sink-duckdb")]
662 "duckdb" => {
663 let cfg = decode::<faucet_sink_duckdb::DuckdbSinkConfig>("sink", "duckdb", config)?;
664 Ok(Box::new(faucet_sink_duckdb::DuckdbSink::new(cfg).await?))
665 }
666 #[cfg(feature = "sink-sqs")]
667 "sqs" => {
668 let cfg = decode::<faucet_sink_sqs::SqsSinkConfig>("sink", "sqs", config)?;
669 Ok(Box::new(faucet_sink_sqs::SqsSink::new(cfg).await?))
670 }
671 #[cfg(feature = "sink-nats")]
672 "nats" => {
673 let cfg = decode::<faucet_sink_nats::NatsSinkConfig>("sink", "nats", config)?;
674 Ok(Box::new(faucet_sink_nats::NatsSink::new(cfg).await?))
675 }
676 #[cfg(feature = "sink-sftp")]
677 "sftp" => {
678 let cfg = decode::<faucet_sink_sftp::SftpSinkConfig>("sink", "sftp", config)?;
679 Ok(Box::new(faucet_sink_sftp::SftpSink::new(cfg)?))
680 }
681 #[cfg(feature = "sink-s3")]
682 "s3" => {
683 let cfg = decode::<faucet_sink_s3::S3SinkConfig>("sink", "s3", config)?;
684 Ok(Box::new(faucet_sink_s3::S3Sink::new(cfg).await?))
685 }
686 #[cfg(feature = "sink-mongodb")]
687 "mongodb" => {
688 let cfg = decode::<faucet_sink_mongodb::MongoSinkConfig>("sink", "mongodb", config)?;
689 Ok(Box::new(faucet_sink_mongodb::MongoSink::new(cfg).await?))
690 }
691 #[cfg(feature = "sink-redis")]
692 "redis" => {
693 let cfg = decode::<faucet_sink_redis::RedisSinkConfig>("sink", "redis", config)?;
694 Ok(Box::new(faucet_sink_redis::RedisSink::new(cfg).await?))
695 }
696 #[cfg(feature = "sink-csv")]
697 "csv" => {
698 let cfg = decode::<faucet_sink_csv::CsvSinkConfig>("sink", "csv", config)?;
699 Ok(Box::new(faucet_sink_csv::CsvSink::new(cfg)))
700 }
701 #[cfg(feature = "sink-elasticsearch")]
702 "elasticsearch" => {
703 let cfg = decode::<faucet_sink_elasticsearch::ElasticsearchSinkConfig>(
704 "sink",
705 "elasticsearch",
706 config,
707 )?;
708 let mut s = faucet_sink_elasticsearch::ElasticsearchSink::new(cfg)?;
709 if let Some(name) = &auth_ref {
710 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
711 }
712 Ok(Box::new(s))
713 }
714 #[cfg(feature = "sink-kafka")]
715 "kafka" => {
716 let cfg = decode::<faucet_sink_kafka::KafkaSinkConfig>("sink", "kafka", config)?;
717 Ok(Box::new(faucet_sink_kafka::KafkaSink::new(cfg).await?))
718 }
719 #[cfg(feature = "sink-kinesis")]
720 "kinesis" => {
721 let cfg = decode::<faucet_sink_kinesis::KinesisSinkConfig>("sink", "kinesis", config)?;
722 Ok(Box::new(faucet_sink_kinesis::KinesisSink::new(cfg).await?))
723 }
724 #[cfg(feature = "sink-spanner")]
725 "spanner" => {
726 let cfg = decode::<faucet_sink_spanner::SpannerSinkConfig>("sink", "spanner", config)?;
727 Ok(Box::new(faucet_sink_spanner::SpannerSink::new(cfg).await?))
728 }
729 #[cfg(feature = "sink-http")]
730 "http" => {
731 let cfg = decode::<faucet_sink_http::HttpSinkConfig>("sink", "http", config)?;
732 let mut s = faucet_sink_http::HttpSink::new(cfg);
733 if let Some(name) = &auth_ref {
734 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
735 }
736 Ok(Box::new(s))
737 }
738 #[cfg(feature = "sink-stdout")]
739 "stdout" => {
740 let cfg = decode::<faucet_sink_stdout::StdoutSinkConfig>("sink", "stdout", config)?;
741 Ok(Box::new(faucet_sink_stdout::StdoutSink::new(cfg)))
742 }
743 #[cfg(feature = "sink-parquet")]
744 "parquet" => {
745 let cfg = decode::<faucet_sink_parquet::ParquetSinkConfig>("sink", "parquet", config)?;
746 Ok(Box::new(faucet_sink_parquet::ParquetSink::new(cfg).await?))
747 }
748 #[cfg(feature = "sink-gcs")]
749 "gcs" => {
750 let cfg = decode::<faucet_sink_gcs::GcsSinkConfig>("sink", "gcs", config)?;
751 Ok(Box::new(faucet_sink_gcs::GcsSink::new(cfg).await?))
752 }
753 #[cfg(feature = "sink-redshift")]
754 "redshift" => {
755 let cfg =
756 decode::<faucet_sink_redshift::RedshiftSinkConfig>("sink", "redshift", config)?;
757 Ok(Box::new(
758 faucet_sink_redshift::RedshiftSink::new(cfg).await?,
759 ))
760 }
761 #[cfg(feature = "sink-pubsub")]
762 "pubsub" => {
763 let cfg = decode::<faucet_sink_pubsub::PubsubSinkConfig>("sink", "pubsub", config)?;
764 Ok(Box::new(faucet_sink_pubsub::PubsubSink::new(cfg).await?))
765 }
766 #[cfg(feature = "sink-clickhouse")]
767 "clickhouse" => {
768 let cfg = decode::<faucet_sink_clickhouse::ClickHouseSinkConfig>(
769 "sink",
770 "clickhouse",
771 config,
772 )?;
773 Ok(Box::new(faucet_sink_clickhouse::ClickHouseSink::new(cfg)?))
774 }
775 #[cfg(feature = "sink-azure-blob")]
776 "azure-blob" => {
777 let cfg = decode::<faucet_sink_azure_blob::AzureBlobSinkConfig>(
778 "sink",
779 "azure-blob",
780 config,
781 )?;
782 Ok(Box::new(
783 faucet_sink_azure_blob::AzureBlobSink::new(cfg).await?,
784 ))
785 }
786 other => Err(unknown(other, "sink", sink_kinds())),
787 }
788}
789
790pub const EXACTLY_ONCE_SOURCE_KINDS: &[&str] = &[
797 "postgres-cdc",
798 "mysql-cdc",
799 "mssql-cdc",
800 "mongodb-cdc",
801 "kafka",
802];
803
804pub const IDEMPOTENT_SINK_KINDS: &[&str] = &[
809 "sqlite",
810 "postgres",
811 "mysql",
812 "mssql",
813 "iceberg",
814 "bigquery",
815 "kafka",
816 "snowflake",
817 "redis",
818 "mongodb",
819 "spanner",
820];
821
822pub const SCHEMA_EVOLUTION_SINK_KINDS: &[&str] = &[
827 "postgres",
828 "mysql",
829 "mssql",
830 "sqlite",
831 "bigquery",
832 "elasticsearch",
833 "spanner",
834 "iceberg",
835];
836
837pub const UPSERT_SINK_KINDS: &[&str] = &[
841 "postgres",
842 "sqlite",
843 "mysql",
844 "mssql",
845 "mongodb",
846 "elasticsearch",
847 "bigquery",
848 "spanner",
849];
850
851pub const CLEANUP_SINK_KINDS: &[&str] = &[
862 "postgres",
863 "sqlite",
864 "mysql",
865 "mssql",
866 "mongodb",
867 "elasticsearch",
868 "bigquery",
869 "spanner",
870];
871
872pub const OVERWRITE_SINK_KINDS: &[&str] = &[
879 "sqlite",
880 "postgres",
881 "mysql",
882 "mssql",
883 "mongodb",
884 "bigquery",
885 "elasticsearch",
888];
889
890pub fn sink_supports_overwrite(kind: &str) -> bool {
892 OVERWRITE_SINK_KINDS.contains(&kind)
893}
894
895pub const SCOPED_OVERWRITE_SINK_KINDS: &[&str] = &["postgres", "bigquery"];
901
902pub fn sink_supports_scoped_overwrite(kind: &str) -> bool {
904 SCOPED_OVERWRITE_SINK_KINDS.contains(&kind)
905}
906
907pub const STAGED_LOAD_SINK_KINDS: &[&str] =
917 &["redshift", "snowflake", "bigquery", "clickhouse", "mssql"];
918
919pub fn sink_supports_staged_load(kind: &str) -> bool {
921 STAGED_LOAD_SINK_KINDS.contains(&kind)
922}
923
924pub const DISCOVER_SOURCE_KINDS: &[&str] = &[
928 "postgres",
929 "mysql",
930 "mssql",
931 "sqlite",
932 "mongodb",
933 "elasticsearch",
934 "bigquery",
935 "snowflake",
936 "spanner",
937 "s3",
938 "gcs",
939];
940
941pub fn source_supports_discover(kind: &str) -> bool {
943 DISCOVER_SOURCE_KINDS.contains(&kind)
944}
945
946pub fn source_replay_guarantee(kind: &str) -> faucet_core::ReplayGuarantee {
951 if EXACTLY_ONCE_SOURCE_KINDS.contains(&kind) {
952 faucet_core::ReplayGuarantee::Deterministic
953 } else {
954 faucet_core::ReplayGuarantee::NonDeterministic
955 }
956}
957
958pub fn sink_guarantee(kind: &str) -> faucet_core::SinkGuarantee {
962 if IDEMPOTENT_SINK_KINDS.contains(&kind) {
963 faucet_core::SinkGuarantee::AtomicWatermark
964 } else if UPSERT_SINK_KINDS.contains(&kind) {
965 faucet_core::SinkGuarantee::KeyedUpsert
966 } else {
967 faucet_core::SinkGuarantee::AtLeastOnce
968 }
969}
970
971pub fn source_supports_exactly_once(kind: &str) -> bool {
973 source_replay_guarantee(kind) == faucet_core::ReplayGuarantee::Deterministic
974}
975
976pub fn sink_supports_idempotent_writes(kind: &str) -> bool {
978 sink_guarantee(kind) == faucet_core::SinkGuarantee::AtomicWatermark
979}
980
981pub fn sink_supports_schema_evolution(kind: &str) -> bool {
983 SCHEMA_EVOLUTION_SINK_KINDS.contains(&kind)
984}
985
986pub fn sink_supports_cleanup(kind: &str) -> bool {
988 CLEANUP_SINK_KINDS.contains(&kind)
989}
990
991pub fn sink_supported_write_modes(kind: &str) -> &'static [faucet_core::WriteMode] {
994 use faucet_core::WriteMode;
995 match (
996 UPSERT_SINK_KINDS.contains(&kind),
997 OVERWRITE_SINK_KINDS.contains(&kind),
998 ) {
999 (true, true) => &[
1000 WriteMode::Append,
1001 WriteMode::Upsert,
1002 WriteMode::Delete,
1003 WriteMode::Overwrite,
1004 ],
1005 (true, false) => &[WriteMode::Append, WriteMode::Upsert, WriteMode::Delete],
1006 (false, true) => &[WriteMode::Append, WriteMode::Overwrite],
1007 (false, false) => &[WriteMode::Append],
1008 }
1009}
1010
1011pub fn source_schema(kind: &str) -> CliResult<Value> {
1013 if let Some(entry) = global().sources.get(kind) {
1014 return Ok((entry.schema)());
1015 }
1016 match kind {
1017 #[cfg(feature = "source-rest")]
1018 "rest" => Ok(schema::<faucet_source_rest::RestStreamConfig>()),
1019 #[cfg(feature = "source-graphql")]
1020 "graphql" => Ok(schema::<faucet_source_graphql::GraphqlStreamConfig>()),
1021 #[cfg(feature = "source-xml")]
1022 "xml" => Ok(schema::<faucet_source_xml::XmlStreamConfig>()),
1023 #[cfg(feature = "source-grpc")]
1024 "grpc" => Ok(schema::<faucet_source_grpc::GrpcStreamConfig>()),
1025 #[cfg(feature = "source-postgres")]
1026 "postgres" => Ok(schema::<faucet_source_postgres::PostgresSourceConfig>()),
1027 #[cfg(feature = "source-postgres-cdc")]
1028 "postgres-cdc" => Ok(schema::<faucet_source_postgres_cdc::PostgresCdcSourceConfig>()),
1029 #[cfg(feature = "source-mysql")]
1030 "mysql" => Ok(schema::<faucet_source_mysql::MysqlSourceConfig>()),
1031 #[cfg(feature = "source-mssql")]
1032 "mssql" => Ok(schema::<faucet_source_mssql::MssqlSourceConfig>()),
1033 #[cfg(feature = "source-sqlite")]
1034 "sqlite" => Ok(schema::<faucet_source_sqlite::SqliteSourceConfig>()),
1035 #[cfg(feature = "source-duckdb")]
1036 "duckdb" => Ok(schema::<faucet_source_duckdb::DuckdbSourceConfig>()),
1037 #[cfg(feature = "source-sqs")]
1038 "sqs" => Ok(schema::<faucet_source_sqs::SqsSourceConfig>()),
1039 #[cfg(feature = "source-nats")]
1040 "nats" => Ok(schema::<faucet_source_nats::NatsSourceConfig>()),
1041 #[cfg(feature = "source-sftp")]
1042 "sftp" => Ok(schema::<faucet_source_sftp::SftpSourceConfig>()),
1043 #[cfg(feature = "source-s3")]
1044 "s3" => Ok(schema::<faucet_source_s3::S3SourceConfig>()),
1045 #[cfg(feature = "source-mongodb")]
1046 "mongodb" => Ok(schema::<faucet_source_mongodb::MongoSourceConfig>()),
1047 #[cfg(feature = "source-mongodb-cdc")]
1048 "mongodb-cdc" => Ok(schema::<faucet_source_mongodb_cdc::MongoCdcSourceConfig>()),
1049 #[cfg(feature = "source-mysql-cdc")]
1050 "mysql-cdc" => Ok(schema::<faucet_source_mysql_cdc::MysqlCdcSourceConfig>()),
1051 #[cfg(feature = "source-redis")]
1052 "redis" => Ok(schema::<faucet_source_redis::RedisSourceConfig>()),
1053 #[cfg(feature = "source-webhook")]
1054 "webhook" => Ok(schema::<faucet_source_webhook::WebhookSourceConfig>()),
1055 #[cfg(feature = "source-websocket")]
1056 "websocket" => Ok(schema::<faucet_source_websocket::WebsocketSourceConfig>()),
1057 #[cfg(feature = "source-csv")]
1058 "csv" => Ok(schema::<faucet_source_csv::CsvSourceConfig>()),
1059 #[cfg(feature = "source-singer")]
1060 "singer" => Ok(schema::<faucet_source_singer::SingerSourceConfig>()),
1061 #[cfg(feature = "source-elasticsearch")]
1062 "elasticsearch" => Ok(schema::<
1063 faucet_source_elasticsearch::ElasticsearchSourceConfig,
1064 >()),
1065 #[cfg(feature = "source-kafka")]
1066 "kafka" => Ok(schema::<faucet_source_kafka::KafkaSourceConfig>()),
1067 #[cfg(feature = "source-kinesis")]
1068 "kinesis" => Ok(schema::<faucet_source_kinesis::KinesisSourceConfig>()),
1069 #[cfg(feature = "source-spanner")]
1070 "spanner" => Ok(schema::<faucet_source_spanner::SpannerSourceConfig>()),
1071 #[cfg(feature = "source-parquet")]
1072 "parquet" => Ok(schema::<faucet_source_parquet::ParquetSourceConfig>()),
1073 #[cfg(feature = "source-delta")]
1074 "delta" => Ok(schema::<faucet_source_delta::DeltaSourceConfig>()),
1075 #[cfg(feature = "source-databricks")]
1076 "databricks" => Ok(schema::<faucet_source_databricks::DatabricksSourceConfig>()),
1077 #[cfg(feature = "source-gcs")]
1078 "gcs" => Ok(schema::<faucet_source_gcs::GcsSourceConfig>()),
1079 #[cfg(feature = "source-bigquery")]
1080 "bigquery" => Ok(schema::<faucet_source_bigquery::BigQuerySourceConfig>()),
1081 #[cfg(feature = "source-snowflake")]
1082 "snowflake" => Ok(schema::<faucet_source_snowflake::SnowflakeSourceConfig>()),
1083 #[cfg(feature = "source-mssql-cdc")]
1084 "mssql-cdc" => Ok(schema::<faucet_source_mssql_cdc::MssqlCdcSourceConfig>()),
1085 #[cfg(feature = "source-redshift")]
1086 "redshift" => Ok(schema::<faucet_source_redshift::RedshiftSourceConfig>()),
1087 #[cfg(feature = "source-pubsub")]
1088 "pubsub" => Ok(schema::<faucet_source_pubsub::PubsubSourceConfig>()),
1089 #[cfg(feature = "source-clickhouse")]
1090 "clickhouse" => Ok(schema::<faucet_source_clickhouse::ClickHouseSourceConfig>()),
1091 #[cfg(feature = "source-azure-blob")]
1092 "azure-blob" => Ok(schema::<faucet_source_azure_blob::AzureBlobSourceConfig>()),
1093 other => Err(unknown(other, "source", source_kinds())),
1094 }
1095}
1096
1097pub fn source_exists(kind: &str) -> bool {
1099 source_schema(kind).is_ok()
1100}
1101
1102pub fn sink_exists(kind: &str) -> bool {
1104 sink_schema(kind).is_ok()
1105}
1106
1107pub fn sink_schema(kind: &str) -> CliResult<Value> {
1109 if let Some(entry) = global().sinks.get(kind) {
1110 return Ok((entry.schema)());
1111 }
1112 match kind {
1113 #[cfg(feature = "sink-bigquery")]
1114 "bigquery" => Ok(schema::<faucet_sink_bigquery::BigQuerySinkConfig>()),
1115 #[cfg(feature = "sink-iceberg")]
1116 "iceberg" => Ok(schema::<faucet_sink_iceberg::IcebergSinkConfig>()),
1117 #[cfg(feature = "sink-delta")]
1118 "delta" => Ok(schema::<faucet_sink_delta::DeltaSinkConfig>()),
1119 #[cfg(feature = "sink-postgres")]
1120 "postgres" => Ok(schema::<faucet_sink_postgres::PostgresSinkConfig>()),
1121 #[cfg(feature = "sink-jsonl")]
1122 "jsonl" => Ok(schema::<faucet_sink_jsonl::JsonlSinkConfig>()),
1123 #[cfg(feature = "sink-snowflake")]
1124 "snowflake" => Ok(schema::<faucet_sink_snowflake::SnowflakeSinkConfig>()),
1125 #[cfg(feature = "sink-mysql")]
1126 "mysql" => Ok(schema::<faucet_sink_mysql::MysqlSinkConfig>()),
1127 #[cfg(feature = "sink-mssql")]
1128 "mssql" => Ok(schema::<faucet_sink_mssql::MssqlSinkConfig>()),
1129 #[cfg(feature = "sink-sqlite")]
1130 "sqlite" => Ok(schema::<faucet_sink_sqlite::SqliteSinkConfig>()),
1131 #[cfg(feature = "sink-duckdb")]
1132 "duckdb" => Ok(schema::<faucet_sink_duckdb::DuckdbSinkConfig>()),
1133 #[cfg(feature = "sink-sqs")]
1134 "sqs" => Ok(schema::<faucet_sink_sqs::SqsSinkConfig>()),
1135 #[cfg(feature = "sink-nats")]
1136 "nats" => Ok(schema::<faucet_sink_nats::NatsSinkConfig>()),
1137 #[cfg(feature = "sink-sftp")]
1138 "sftp" => Ok(schema::<faucet_sink_sftp::SftpSinkConfig>()),
1139 #[cfg(feature = "sink-s3")]
1140 "s3" => Ok(schema::<faucet_sink_s3::S3SinkConfig>()),
1141 #[cfg(feature = "sink-mongodb")]
1142 "mongodb" => Ok(schema::<faucet_sink_mongodb::MongoSinkConfig>()),
1143 #[cfg(feature = "sink-redis")]
1144 "redis" => Ok(schema::<faucet_sink_redis::RedisSinkConfig>()),
1145 #[cfg(feature = "sink-csv")]
1146 "csv" => Ok(schema::<faucet_sink_csv::CsvSinkConfig>()),
1147 #[cfg(feature = "sink-elasticsearch")]
1148 "elasticsearch" => Ok(schema::<faucet_sink_elasticsearch::ElasticsearchSinkConfig>()),
1149 #[cfg(feature = "sink-kafka")]
1150 "kafka" => Ok(schema::<faucet_sink_kafka::KafkaSinkConfig>()),
1151 #[cfg(feature = "sink-kinesis")]
1152 "kinesis" => Ok(schema::<faucet_sink_kinesis::KinesisSinkConfig>()),
1153 #[cfg(feature = "sink-spanner")]
1154 "spanner" => Ok(schema::<faucet_sink_spanner::SpannerSinkConfig>()),
1155 #[cfg(feature = "sink-http")]
1156 "http" => Ok(schema::<faucet_sink_http::HttpSinkConfig>()),
1157 #[cfg(feature = "sink-stdout")]
1158 "stdout" => Ok(schema::<faucet_sink_stdout::StdoutSinkConfig>()),
1159 #[cfg(feature = "sink-parquet")]
1160 "parquet" => Ok(schema::<faucet_sink_parquet::ParquetSinkConfig>()),
1161 #[cfg(feature = "sink-gcs")]
1162 "gcs" => Ok(schema::<faucet_sink_gcs::GcsSinkConfig>()),
1163 #[cfg(feature = "sink-redshift")]
1164 "redshift" => Ok(schema::<faucet_sink_redshift::RedshiftSinkConfig>()),
1165 #[cfg(feature = "sink-pubsub")]
1166 "pubsub" => Ok(schema::<faucet_sink_pubsub::PubsubSinkConfig>()),
1167 #[cfg(feature = "sink-clickhouse")]
1168 "clickhouse" => Ok(schema::<faucet_sink_clickhouse::ClickHouseSinkConfig>()),
1169 #[cfg(feature = "sink-azure-blob")]
1170 "azure-blob" => Ok(schema::<faucet_sink_azure_blob::AzureBlobSinkConfig>()),
1171 other => Err(unknown(other, "sink", sink_kinds())),
1172 }
1173}
1174
1175pub fn source_descriptions() -> Vec<(&'static str, &'static str)> {
1179 let mut v = builtin_source_descriptions();
1180 v.extend(global().custom_source_descriptions());
1181 v
1182}
1183
1184#[allow(clippy::vec_init_then_push)]
1186fn builtin_source_descriptions() -> Vec<(&'static str, &'static str)> {
1187 let mut v: Vec<(&'static str, &'static str)> = Vec::new();
1188 #[cfg(feature = "source-rest")]
1189 v.push(("rest", "REST API source with pagination, auth, transforms"));
1190 #[cfg(feature = "source-graphql")]
1191 v.push(("graphql", "GraphQL API source with cursor pagination"));
1192 #[cfg(feature = "source-xml")]
1193 v.push(("xml", "XML / SOAP API source with XML→JSON conversion"));
1194 #[cfg(feature = "source-grpc")]
1195 v.push(("grpc", "gRPC source with dynamic protobuf"));
1196 #[cfg(feature = "source-postgres")]
1197 v.push(("postgres", "PostgreSQL query source"));
1198 #[cfg(feature = "source-postgres-cdc")]
1199 v.push((
1200 "postgres-cdc",
1201 "PostgreSQL CDC source (logical replication)",
1202 ));
1203 #[cfg(feature = "source-mysql")]
1204 v.push(("mysql", "MySQL query source"));
1205 #[cfg(feature = "source-mssql")]
1206 v.push(("mssql", "Microsoft SQL Server query source"));
1207 #[cfg(feature = "source-sqlite")]
1208 v.push(("sqlite", "SQLite query source"));
1209 #[cfg(feature = "source-duckdb")]
1210 v.push((
1211 "duckdb",
1212 "DuckDB query source. Runs SQL against a DuckDB file or in-memory database and streams rows as JSON with bounded memory.",
1213 ));
1214 #[cfg(feature = "source-sqs")]
1215 v.push((
1216 "sqs",
1217 "AWS SQS source. Long-polls ReceiveMessage, deletes after the batch is emitted (at-least-once), with idle/max-messages termination.",
1218 ));
1219 #[cfg(feature = "source-nats")]
1220 v.push((
1221 "nats",
1222 "NATS source. Subscribes to a subject (or a JetStream durable consumer) and drains with idle/max-messages termination.",
1223 ));
1224 #[cfg(feature = "source-sftp")]
1225 v.push((
1226 "sftp",
1227 "SFTP source. Lists/globs a remote directory and streams JSONL / JSON-array / raw-text files over SSH.",
1228 ));
1229 #[cfg(feature = "source-s3")]
1230 v.push(("s3", "AWS S3 object source"));
1231 #[cfg(feature = "source-mongodb")]
1232 v.push(("mongodb", "MongoDB query source"));
1233 #[cfg(feature = "source-mongodb-cdc")]
1234 v.push(("mongodb-cdc", "MongoDB CDC source (Change Streams)"));
1235 #[cfg(feature = "source-mysql-cdc")]
1236 v.push(("mysql-cdc", "MySQL CDC source (binlog replication)"));
1237 #[cfg(feature = "source-mssql-cdc")]
1238 v.push((
1239 "mssql-cdc",
1240 "Microsoft SQL Server CDC source (change data capture, exactly-once capable)",
1241 ));
1242 #[cfg(feature = "source-redshift")]
1243 v.push((
1244 "redshift",
1245 "Amazon Redshift query source (PostgreSQL wire; streaming rows, incremental replication)",
1246 ));
1247 #[cfg(feature = "source-pubsub")]
1248 v.push((
1249 "pubsub",
1250 "Google Cloud Pub/Sub consumer — streaming pull with per-message records, attribute mapping, and ack at durable page boundaries (at-least-once)",
1251 ));
1252 #[cfg(feature = "source-clickhouse")]
1253 v.push((
1254 "clickhouse",
1255 "ClickHouse query source (HTTP interface, JSONEachRow streaming)",
1256 ));
1257 #[cfg(feature = "source-azure-blob")]
1258 v.push((
1259 "azure-blob",
1260 "Azure Blob Storage / ADLS Gen2 source — JSONL, JSON array, or raw text",
1261 ));
1262 #[cfg(feature = "source-redis")]
1263 v.push(("redis", "Redis (streams, lists, keys) source"));
1264 #[cfg(feature = "source-webhook")]
1265 v.push(("webhook", "Webhook HTTP receiver source"));
1266 #[cfg(feature = "source-websocket")]
1267 v.push((
1268 "websocket",
1269 "WebSocket streaming source — connects, subscribes, streams each message as a record",
1270 ));
1271 #[cfg(feature = "source-csv")]
1272 v.push(("csv", "CSV file source"));
1273 #[cfg(feature = "source-singer")]
1274 v.push((
1275 "singer",
1276 "Singer tap bridge (runs an external Singer tap; single-stream v0, Tier-2/experimental)",
1277 ));
1278 #[cfg(feature = "source-elasticsearch")]
1279 v.push(("elasticsearch", "Elasticsearch search / scroll source"));
1280 #[cfg(feature = "source-kafka")]
1281 v.push(("kafka", "Apache Kafka consumer (rdkafka). Subscribes to topics and drains messages with idle/max-messages termination."));
1282 #[cfg(feature = "source-kinesis")]
1283 v.push(("kinesis", "AWS Kinesis Data Streams consumer. Per-shard workers with resumable sequence-number checkpoints and idle/max-messages termination."));
1284 #[cfg(feature = "source-spanner")]
1285 v.push(("spanner", "Google Cloud Spanner query source. Streaming SQL reads with incremental replication bookmarks, stale reads, and PK-range sharding."));
1286 #[cfg(feature = "source-parquet")]
1287 v.push(("parquet", "Apache Parquet file source (local path, glob, or S3). Streams record batches via the Arrow async reader."));
1288 #[cfg(feature = "source-delta")]
1289 v.push(("delta", "Apache Delta Lake source (local FS or S3/Azure/GCS). Streams active data files with time travel and projection pushdown."));
1290 #[cfg(feature = "source-databricks")]
1291 v.push(("databricks", "Databricks SQL query source (Statement Execution API). Streams typed query results with chunk pagination and incremental replication."));
1292 #[cfg(feature = "source-gcs")]
1293 v.push((
1294 "gcs",
1295 "Google Cloud Storage source — JSONL, JSON array, or raw text",
1296 ));
1297 #[cfg(feature = "source-bigquery")]
1298 v.push((
1299 "bigquery",
1300 "Google BigQuery query source (jobs.query + jobs.getQueryResults)",
1301 ));
1302 #[cfg(feature = "source-snowflake")]
1303 v.push((
1304 "snowflake",
1305 "Snowflake query source (SQL REST API with partition paging)",
1306 ));
1307 v
1308}
1309
1310pub fn sink_descriptions() -> Vec<(&'static str, &'static str)> {
1314 let mut v = builtin_sink_descriptions();
1315 v.extend(global().custom_sink_descriptions());
1316 v
1317}
1318
1319#[allow(clippy::vec_init_then_push)]
1321fn builtin_sink_descriptions() -> Vec<(&'static str, &'static str)> {
1322 let mut v: Vec<(&'static str, &'static str)> = Vec::new();
1323 #[cfg(feature = "sink-bigquery")]
1324 v.push(("bigquery", "Google BigQuery streaming-insert sink"));
1325 #[cfg(feature = "sink-iceberg")]
1326 v.push((
1327 "iceberg",
1328 "Apache Iceberg sink (append, REST/Glue/SQL/HMS catalogs)",
1329 ));
1330 #[cfg(feature = "sink-postgres")]
1331 v.push(("postgres", "PostgreSQL sink (JSONB or auto-mapped columns)"));
1332 #[cfg(feature = "sink-jsonl")]
1333 v.push(("jsonl", "JSON Lines file sink"));
1334 #[cfg(feature = "sink-snowflake")]
1335 v.push(("snowflake", "Snowflake SQL REST API sink"));
1336 #[cfg(feature = "sink-mysql")]
1337 v.push(("mysql", "MySQL sink"));
1338 #[cfg(feature = "sink-mssql")]
1339 v.push((
1340 "mssql",
1341 "Microsoft SQL Server sink (auto-mapped columns or JSON column)",
1342 ));
1343 #[cfg(feature = "sink-sqlite")]
1344 v.push(("sqlite", "SQLite sink"));
1345 #[cfg(feature = "sink-duckdb")]
1346 v.push((
1347 "duckdb",
1348 "DuckDB sink. Transaction-wrapped multi-row INSERT (JSON column or auto-mapped columns).",
1349 ));
1350 #[cfg(feature = "sink-sqs")]
1351 v.push((
1352 "sqs",
1353 "AWS SQS sink. Batched SendMessageBatch (10-message chunks) with per-entry partial-failure retry; FIFO group/dedup support.",
1354 ));
1355 #[cfg(feature = "sink-nats")]
1356 v.push((
1357 "nats",
1358 "NATS sink. Publishes records to a subject (optionally subject-per-record) and flushes per batch.",
1359 ));
1360 #[cfg(feature = "sink-sftp")]
1361 v.push((
1362 "sftp",
1363 "SFTP sink. Writes JSONL files over SSH with atomic temp-then-rename uploads.",
1364 ));
1365 #[cfg(feature = "sink-s3")]
1366 v.push(("s3", "AWS S3 object sink"));
1367 #[cfg(feature = "sink-mongodb")]
1368 v.push(("mongodb", "MongoDB insert sink"));
1369 #[cfg(feature = "sink-redis")]
1370 v.push(("redis", "Redis (streams, lists, key-value) sink"));
1371 #[cfg(feature = "sink-csv")]
1372 v.push(("csv", "CSV file sink"));
1373 #[cfg(feature = "sink-elasticsearch")]
1374 v.push(("elasticsearch", "Elasticsearch bulk index sink"));
1375 #[cfg(feature = "sink-kafka")]
1376 v.push(("kafka", "Apache Kafka producer (rdkafka). FuturesUnordered batched sends with QueueFull retry; supports fixed or per-record topic routing."));
1377 #[cfg(feature = "sink-kinesis")]
1378 v.push(("kinesis", "AWS Kinesis Data Streams producer. Batched PutRecords with partition-key routing and partial-failure retry (DLQ-routable)."));
1379 #[cfg(feature = "sink-spanner")]
1380 v.push(("spanner", "Google Cloud Spanner sink. Batched mutations with upsert/delete write modes, exactly-once commit tokens, and schema evolution."));
1381 #[cfg(feature = "sink-http")]
1382 v.push(("http", "HTTP POST sink (individual or array batch)"));
1383 #[cfg(feature = "sink-stdout")]
1384 v.push(("stdout", "Stdout / stderr sink (JSON Lines, pretty, TSV)"));
1385 #[cfg(feature = "sink-parquet")]
1386 v.push(("parquet", "Apache Parquet file sink (local path or S3). Schema-inferred, configurable compression, row/byte rollover."));
1387 #[cfg(feature = "sink-delta")]
1388 v.push(("delta", "Apache Delta Lake sink (local FS or S3/Azure/GCS). Append-only, schema-inferred table creation, one commit per flush."));
1389 #[cfg(feature = "sink-gcs")]
1390 v.push(("gcs", "Google Cloud Storage sink — JSONL files"));
1391 #[cfg(feature = "sink-redshift")]
1392 v.push((
1393 "redshift",
1394 "Amazon Redshift sink (COPY-from-S3 or multi-row INSERT)",
1395 ));
1396 #[cfg(feature = "sink-pubsub")]
1397 v.push((
1398 "pubsub",
1399 "Google Cloud Pub/Sub producer — batched publish with optional ordering keys, bounded concurrency, and partial-failure retry (DLQ-routable)",
1400 ));
1401 #[cfg(feature = "sink-clickhouse")]
1402 v.push((
1403 "clickhouse",
1404 "ClickHouse sink (HTTP INSERT … FORMAT JSONEachRow; optional async inserts)",
1405 ));
1406 #[cfg(feature = "sink-azure-blob")]
1407 v.push((
1408 "azure-blob",
1409 "Azure Blob Storage / ADLS Gen2 sink — JSONL files",
1410 ));
1411 v
1412}
1413
1414pub fn source_kinds() -> Vec<&'static str> {
1416 source_descriptions().into_iter().map(|(k, _)| k).collect()
1417}
1418
1419pub fn sink_kinds() -> Vec<&'static str> {
1421 sink_descriptions().into_iter().map(|(k, _)| k).collect()
1422}
1423
1424fn decode<T: DeserializeOwned>(kind: &'static str, name: &str, config: Value) -> CliResult<T> {
1425 serde_json::from_value(config).map_err(|e| CliError::InvalidConnectorConfig {
1426 kind,
1427 name: name.to_owned(),
1428 message: scrub_config_error(&e.to_string()),
1429 })
1430}
1431
1432fn scrub_config_error(msg: &str) -> String {
1442 const MAX_CHARS: usize = 200;
1443 let mut out = String::with_capacity(msg.len());
1444 let mut in_quote = false;
1445 for c in msg.chars() {
1446 if c == '"' {
1447 if !in_quote {
1448 out.push_str("\"<redacted>\"");
1449 }
1450 in_quote = !in_quote;
1451 continue;
1452 }
1453 if !in_quote {
1454 out.push(c);
1455 }
1456 }
1457 if out.chars().count() > MAX_CHARS {
1458 let truncated: String = out.chars().take(MAX_CHARS).collect();
1459 return format!("{truncated}…");
1460 }
1461 out
1462}
1463
1464fn schema<T: faucet_core::JsonSchema>() -> Value {
1465 serde_json::to_value(faucet_core::schema_for!(T))
1466 .unwrap_or_else(|_| serde_json::json!({"type": "object"}))
1467}
1468
1469fn unknown(name: &str, kind: &'static str, available: Vec<&'static str>) -> CliError {
1470 CliError::UnknownConnector {
1471 kind,
1472 name: name.to_owned(),
1473 available: if available.is_empty() {
1474 "(none — rebuild faucet-cli with the relevant feature enabled)".to_owned()
1475 } else {
1476 available.join(", ")
1477 },
1478 }
1479}
1480
1481#[cfg(test)]
1482mod tests {
1483 use super::*;
1484
1485 #[test]
1486 fn staged_load_allowlist_matches_capable_sinks() {
1487 for k in ["redshift", "snowflake", "bigquery", "clickhouse", "mssql"] {
1488 assert!(
1489 sink_supports_staged_load(k),
1490 "{k} should support staged load"
1491 );
1492 }
1493 for k in ["jsonl", "postgres", "sqlite", "stdout"] {
1494 assert!(
1495 !sink_supports_staged_load(k),
1496 "{k} should not (yet) support staged load"
1497 );
1498 }
1499 }
1500
1501 #[derive(Clone)]
1504 struct DummySource;
1505 #[faucet_core::async_trait]
1506 impl Source for DummySource {
1507 async fn fetch_with_context(
1508 &self,
1509 _ctx: &std::collections::HashMap<String, Value>,
1510 ) -> Result<Vec<Value>, faucet_core::FaucetError> {
1511 Ok(vec![serde_json::json!({"ok": true})])
1512 }
1513 fn config_schema(&self) -> Value {
1514 serde_json::json!({"type": "object"})
1515 }
1516 }
1517
1518 #[test]
1519 fn register_source_rejects_builtin_collision() {
1520 let reg = PluginRegistry::with_builtins()
1523 .register_source("csv", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1524 let err = reg
1527 .install()
1528 .expect_err("built-in collision must be rejected");
1529 match err {
1530 CliError::Config(msg) => assert!(msg.contains("built-in source"), "{msg}"),
1531 other => panic!("expected Config error, got {other:?}"),
1532 }
1533 }
1534
1535 #[test]
1536 fn register_source_rejects_duplicate() {
1537 let reg = PluginRegistry::new()
1538 .register_source("dup", |_| Ok(Box::new(DummySource) as Box<dyn Source>))
1539 .register_source("dup", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1540 let err = reg
1541 .install()
1542 .expect_err("duplicate registration must be rejected");
1543 match err {
1544 CliError::Config(msg) => assert!(msg.contains("more than once"), "{msg}"),
1545 other => panic!("expected Config error, got {other:?}"),
1546 }
1547 }
1548
1549 #[test]
1550 fn register_sink_rejects_duplicate() {
1551 let reg = PluginRegistry::new()
1555 .register_sink("dupsink", |_| Err(CliError::Config("unused".into())))
1556 .register_sink("dupsink", |_| Err(CliError::Config("unused".into())));
1557 assert!(
1558 reg.errors.iter().any(|e| e.contains("more than once")),
1559 "{:?}",
1560 reg.errors
1561 );
1562 }
1563
1564 #[test]
1565 fn custom_descriptions_use_default_when_blank() {
1566 let reg = PluginRegistry::new()
1567 .register_source("acme", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1568 let descs = reg.custom_source_descriptions();
1569 assert_eq!(descs.len(), 1);
1570 assert_eq!(descs[0].0, "acme");
1571 assert_eq!(descs[0].1, "custom source connector");
1572 }
1573
1574 #[test]
1575 fn custom_descriptions_carry_explicit_summary() {
1576 let reg = PluginRegistry::new().register_source_with(
1577 "acme",
1578 |_| Ok(Box::new(DummySource) as Box<dyn Source>),
1579 || serde_json::json!({"type": "object", "title": "acme"}),
1580 "Acme widget source",
1581 );
1582 let descs = reg.custom_source_descriptions();
1583 assert_eq!(descs[0], ("acme", "Acme widget source"));
1584 assert_eq!(
1586 (reg.sources.get("acme").unwrap().schema)()["title"],
1587 serde_json::json!("acme")
1588 );
1589 }
1590
1591 #[test]
1592 fn capability_constants_match_their_predicates() {
1593 for &k in EXACTLY_ONCE_SOURCE_KINDS {
1599 assert!(
1600 source_supports_exactly_once(k),
1601 "{k} should be exactly-once"
1602 );
1603 }
1604 for &k in IDEMPOTENT_SINK_KINDS {
1605 assert!(
1606 sink_supports_idempotent_writes(k),
1607 "{k} should be idempotent"
1608 );
1609 }
1610 for &k in UPSERT_SINK_KINDS {
1611 use faucet_core::WriteMode;
1612 assert!(
1613 sink_supported_write_modes(k).contains(&WriteMode::Upsert),
1614 "{k} should support upsert"
1615 );
1616 }
1617 assert!(IDEMPOTENT_SINK_KINDS.contains(&"bigquery"));
1618 assert!(IDEMPOTENT_SINK_KINDS.contains(&"kafka"));
1619 assert!(UPSERT_SINK_KINDS.contains(&"bigquery"));
1620 }
1621
1622 #[cfg(feature = "source-rest")]
1623 #[test]
1624 fn rest_source_appears_in_listings() {
1625 assert!(source_kinds().contains(&"rest"));
1626 }
1627
1628 #[cfg(feature = "sink-jsonl")]
1629 #[test]
1630 fn jsonl_sink_appears_in_listings() {
1631 assert!(sink_kinds().contains(&"jsonl"));
1632 }
1633
1634 #[tokio::test]
1635 async fn unknown_source_kind_errors() {
1636 let err = build_source("nope", serde_json::json!({}), &AuthCatalog::new(), None)
1637 .await
1638 .err()
1639 .expect("should fail");
1640 match err {
1641 CliError::UnknownConnector { kind, name, .. } => {
1642 assert_eq!(kind, "source");
1643 assert_eq!(name, "nope");
1644 }
1645 other => panic!("expected UnknownConnector, got {other:?}"),
1646 }
1647 }
1648
1649 #[tokio::test]
1650 async fn unknown_sink_kind_errors() {
1651 let err = build_sink("nope", serde_json::json!({}), &AuthCatalog::new())
1652 .await
1653 .err()
1654 .expect("should fail");
1655 assert!(matches!(
1656 err,
1657 CliError::UnknownConnector { kind: "sink", .. }
1658 ));
1659 }
1660
1661 #[cfg(feature = "source-rest")]
1662 #[test]
1663 fn rest_schema_is_object() {
1664 let s = source_schema("rest").unwrap();
1665 assert!(s.is_object());
1666 }
1667
1668 #[cfg(feature = "sink-jsonl")]
1669 #[test]
1670 fn jsonl_schema_is_object() {
1671 let s = sink_schema("jsonl").unwrap();
1672 assert!(s.is_object());
1673 }
1674
1675 #[test]
1676 fn scrub_config_error_redacts_quoted_values() {
1677 let msg =
1680 r#"invalid type: string "sk-super-secret-123", expected a sequence at line 1 column 9"#;
1681 let scrubbed = scrub_config_error(msg);
1682 assert!(!scrubbed.contains("sk-super-secret-123"), "{scrubbed}");
1683 assert!(scrubbed.contains("<redacted>"), "{scrubbed}");
1684 assert!(scrubbed.contains("invalid type"), "{scrubbed}");
1686 assert!(scrubbed.contains("expected a sequence"), "{scrubbed}");
1687 }
1688
1689 #[test]
1690 fn scrub_config_error_truncates_long_messages() {
1691 let msg = "x".repeat(500);
1692 let scrubbed = scrub_config_error(&msg);
1693 assert!(
1694 scrubbed.chars().count() <= 201,
1695 "len {}",
1696 scrubbed.chars().count()
1697 );
1698 assert!(scrubbed.ends_with('…'));
1699 }
1700
1701 #[cfg(feature = "source-csv")]
1705 #[tokio::test]
1706 async fn build_source_csv_succeeds_without_io() {
1707 let src = build_source(
1708 "csv",
1709 serde_json::json!({ "path": "/tmp/does-not-need-to-exist.csv" }),
1710 &AuthCatalog::new(),
1711 None,
1712 )
1713 .await
1714 .expect("csv source should build without I/O");
1715 assert_eq!(src.connector_name(), "csv");
1718 }
1719
1720 #[cfg(feature = "sink-jsonl")]
1723 #[tokio::test]
1724 async fn build_sink_jsonl_succeeds_without_io() {
1725 let sink = build_sink(
1726 "jsonl",
1727 serde_json::json!({ "path": "/tmp/does-not-need-to-exist.jsonl" }),
1728 &AuthCatalog::new(),
1729 )
1730 .await
1731 .expect("jsonl sink should build without I/O");
1732 assert_eq!(sink.connector_name(), "jsonl");
1733 }
1734
1735 #[cfg(feature = "sink-stdout")]
1737 #[tokio::test]
1738 async fn build_sink_stdout_succeeds_without_io() {
1739 let sink = build_sink("stdout", serde_json::json!({}), &AuthCatalog::new())
1740 .await
1741 .expect("stdout sink should build without I/O");
1742 assert_eq!(sink.connector_name(), "stdout");
1745 }
1746
1747 #[cfg(all(feature = "source-delta", feature = "sink-delta"))]
1751 #[tokio::test]
1752 async fn delta_registry_round_trip() {
1753 let dir = tempfile::tempdir().unwrap();
1754 let uri = dir.path().join("reg_delta").to_string_lossy().into_owned();
1755
1756 assert!(source_schema("delta").is_ok());
1757 assert!(sink_schema("delta").is_ok());
1758 assert!(source_descriptions().iter().any(|(n, _)| *n == "delta"));
1759 assert!(sink_descriptions().iter().any(|(n, _)| *n == "delta"));
1760
1761 let sink = build_sink(
1762 "delta",
1763 serde_json::json!({ "table_uri": uri }),
1764 &AuthCatalog::new(),
1765 )
1766 .await
1767 .expect("delta sink builds");
1768 assert_eq!(sink.connector_name(), "delta");
1769 let n = sink
1770 .write_batch(&[serde_json::json!({"id": 1}), serde_json::json!({"id": 2})])
1771 .await
1772 .expect("write");
1773 assert_eq!(n, 2);
1774 sink.flush().await.expect("flush");
1775
1776 let source = build_source(
1777 "delta",
1778 serde_json::json!({ "table_uri": uri }),
1779 &AuthCatalog::new(),
1780 None,
1781 )
1782 .await
1783 .expect("delta source builds");
1784 assert_eq!(source.connector_name(), "delta");
1785 let rows = source
1786 .fetch_with_context(&std::collections::HashMap::new())
1787 .await
1788 .expect("read");
1789 assert_eq!(rows.len(), 2);
1790 }
1791
1792 #[cfg(feature = "source-databricks")]
1795 #[tokio::test]
1796 async fn databricks_registry_source_builds() {
1797 assert!(source_schema("databricks").is_ok());
1798 assert!(
1799 source_descriptions()
1800 .iter()
1801 .any(|(n, _)| *n == "databricks")
1802 );
1803 let cfg = serde_json::json!({
1804 "workspace_url": "https://x.cloud.databricks.com",
1805 "warehouse_id": "wh1",
1806 "sql": "SELECT 1",
1807 "auth": { "type": "pat", "config": { "token": "t" } }
1808 });
1809 let src = build_source("databricks", cfg, &AuthCatalog::new(), None)
1810 .await
1811 .expect("databricks source builds");
1812 assert_eq!(src.connector_name(), "databricks");
1813 }
1814
1815 #[cfg(feature = "source-csv")]
1818 #[tokio::test]
1819 async fn build_source_csv_invalid_config_errors() {
1820 let res = build_source(
1824 "csv",
1825 serde_json::json!({ "path": 42 }),
1826 &AuthCatalog::new(),
1827 None,
1828 )
1829 .await;
1830 match res {
1831 Err(CliError::InvalidConnectorConfig { kind, name, .. }) => {
1832 assert_eq!(kind, "source");
1833 assert_eq!(name, "csv");
1834 }
1835 Ok(_) => panic!("expected InvalidConnectorConfig, got Ok"),
1836 Err(other) => panic!("expected InvalidConnectorConfig, got {other:?}"),
1837 }
1838 }
1839
1840 #[cfg(feature = "source-csv")]
1845 #[tokio::test]
1846 async fn build_source_csv_rejects_oversized_batch_size() {
1847 let res = build_source(
1848 "csv",
1849 serde_json::json!({
1850 "path": "/tmp/x.csv",
1851 "batch_size": faucet_core::MAX_BATCH_SIZE + 1
1852 }),
1853 &AuthCatalog::new(),
1854 None,
1855 )
1856 .await;
1857 assert!(matches!(
1858 res,
1859 Err(CliError::Faucet(faucet_core::FaucetError::Config(_)))
1860 ));
1861 }
1862
1863 #[cfg(feature = "source-graphql")]
1864 #[tokio::test]
1865 async fn build_source_graphql_rejects_empty_endpoint() {
1866 let res = build_source(
1867 "graphql",
1868 serde_json::json!({
1869 "endpoint": "",
1870 "query": "query { x }",
1871 "variables": {},
1872 "auth": { "type": "none" }
1873 }),
1874 &AuthCatalog::new(),
1875 None,
1876 )
1877 .await;
1878 assert!(matches!(
1879 res,
1880 Err(CliError::Faucet(faucet_core::FaucetError::Config(_)))
1881 ));
1882 }
1883
1884 #[cfg(feature = "source-csv")]
1887 #[test]
1888 fn source_schema_csv_exposes_path_property() {
1889 let schema = source_schema("csv").expect("csv schema");
1890 let props = schema
1891 .get("properties")
1892 .and_then(Value::as_object)
1893 .expect("schema should have a properties object");
1894 assert!(props.contains_key("path"), "schema props: {props:?}");
1895 }
1896
1897 #[cfg(feature = "sink-jsonl")]
1898 #[test]
1899 fn sink_schema_jsonl_exposes_path_property() {
1900 let schema = sink_schema("jsonl").expect("jsonl schema");
1901 let props = schema
1902 .get("properties")
1903 .and_then(Value::as_object)
1904 .expect("schema should have a properties object");
1905 assert!(props.contains_key("path"), "schema props: {props:?}");
1906 }
1907
1908 #[test]
1909 fn unknown_source_schema_errors_with_available_list() {
1910 let err = source_schema("definitely-not-a-source").expect_err("unknown source");
1911 match err {
1912 CliError::UnknownConnector {
1913 kind,
1914 name,
1915 available,
1916 } => {
1917 assert_eq!(kind, "source");
1918 assert_eq!(name, "definitely-not-a-source");
1919 assert!(!available.is_empty());
1921 }
1922 other => panic!("expected UnknownConnector, got {other:?}"),
1923 }
1924 }
1925
1926 #[test]
1927 fn unknown_sink_schema_errors() {
1928 let err = sink_schema("definitely-not-a-sink").expect_err("unknown sink");
1929 assert!(matches!(
1930 err,
1931 CliError::UnknownConnector { kind: "sink", .. }
1932 ));
1933 }
1934
1935 #[cfg(feature = "source-csv")]
1936 #[test]
1937 fn source_exists_is_true_for_known_and_false_for_unknown() {
1938 assert!(source_exists("csv"));
1939 assert!(!source_exists("definitely-not-a-source"));
1940 }
1941
1942 #[cfg(feature = "sink-jsonl")]
1943 #[test]
1944 fn sink_exists_is_true_for_known_and_false_for_unknown() {
1945 assert!(sink_exists("jsonl"));
1946 assert!(!sink_exists("definitely-not-a-sink"));
1947 }
1948
1949 #[test]
1952 fn source_descriptions_are_non_empty_and_consistent() {
1953 let descs = source_descriptions();
1954 assert!(!descs.is_empty());
1955 for (name, summary) in &descs {
1956 assert!(!name.is_empty(), "empty connector name");
1957 assert!(!summary.is_empty(), "empty summary for {name}");
1958 assert!(
1959 source_schema(name).is_ok(),
1960 "listed source `{name}` has no schema"
1961 );
1962 }
1963 }
1964
1965 #[test]
1966 fn sink_descriptions_are_non_empty_and_consistent() {
1967 let descs = sink_descriptions();
1968 assert!(!descs.is_empty());
1969 for (name, summary) in &descs {
1970 assert!(!name.is_empty(), "empty connector name");
1971 assert!(!summary.is_empty(), "empty summary for {name}");
1972 assert!(
1973 sink_schema(name).is_ok(),
1974 "listed sink `{name}` has no schema"
1975 );
1976 }
1977 }
1978
1979 #[cfg(all(feature = "source-csv", feature = "source-rest"))]
1982 #[test]
1983 fn source_kinds_contains_expected_builtins() {
1984 let kinds = source_kinds();
1985 assert!(kinds.contains(&"csv"));
1986 assert!(kinds.contains(&"rest"));
1987 }
1988
1989 #[cfg(all(feature = "sink-jsonl", feature = "sink-stdout"))]
1990 #[test]
1991 fn sink_kinds_contains_expected_builtins() {
1992 let kinds = sink_kinds();
1993 assert!(kinds.contains(&"jsonl"));
1994 assert!(kinds.contains(&"stdout"));
1995 }
1996
1997 #[cfg(feature = "source-rest")]
2001 #[tokio::test]
2002 async fn build_source_injects_referenced_auth_provider() {
2003 let mut specs = std::collections::HashMap::new();
2004 specs.insert(
2005 "tok".to_string(),
2006 serde_json::json!({"type": "static", "config": {"token": "abc"}}),
2007 );
2008 let catalog = auth_catalog::build_auth_catalog(Some(&specs)).expect("catalog");
2009
2010 let src = build_source("rest", rest_config_with_auth_ref("tok"), &catalog, None)
2011 .await
2012 .expect("rest source with a resolvable auth ref should build");
2013 assert_eq!(src.connector_name(), "rest");
2014 }
2015
2016 #[cfg(feature = "source-rest")]
2019 fn rest_config_with_auth_ref(name: &str) -> Value {
2020 let cfg = faucet_source_rest::RestStreamConfig::new("https://api.example.com", "/v1");
2021 let mut v = serde_json::to_value(cfg).expect("serialize rest config");
2022 v.as_object_mut()
2023 .unwrap()
2024 .insert("auth".to_string(), serde_json::json!({ "ref": name }));
2025 v
2026 }
2027
2028 #[cfg(feature = "source-rest")]
2031 #[tokio::test]
2032 async fn build_source_unknown_auth_ref_errors() {
2033 let res = build_source(
2034 "rest",
2035 rest_config_with_auth_ref("missing"),
2036 &AuthCatalog::new(),
2037 None,
2038 )
2039 .await;
2040 match res {
2041 Err(CliError::UnknownAuthProvider { name, .. }) => assert_eq!(name, "missing"),
2042 Ok(_) => panic!("expected UnknownAuthProvider, got Ok"),
2043 Err(other) => panic!("expected UnknownAuthProvider, got {other:?}"),
2044 }
2045 }
2046
2047 #[test]
2048 fn exactly_once_capability_allowlists() {
2049 assert!(source_supports_exactly_once("postgres-cdc"));
2050 assert!(source_supports_exactly_once("mysql-cdc"));
2051 assert!(source_supports_exactly_once("mongodb-cdc"));
2052 assert!(source_supports_exactly_once("kafka"));
2053 assert!(!source_supports_exactly_once("rest"));
2054
2055 assert!(sink_supports_idempotent_writes("postgres"));
2056 assert!(sink_supports_idempotent_writes("iceberg"));
2057 assert!(sink_supports_idempotent_writes("bigquery"));
2058 assert!(sink_supports_idempotent_writes("kafka"));
2059 assert!(sink_supports_idempotent_writes("snowflake"));
2060 assert!(sink_supports_idempotent_writes("redis"));
2061 assert!(sink_supports_idempotent_writes("mongodb"));
2062 assert!(!sink_supports_idempotent_writes("jsonl"));
2063 }
2064
2065 #[test]
2066 fn typed_delivery_capabilities_derive_from_kind_tables() {
2067 use faucet_core::{ReplayGuarantee, SinkGuarantee};
2068 assert_eq!(
2069 source_replay_guarantee("kafka"),
2070 ReplayGuarantee::Deterministic
2071 );
2072 assert_eq!(
2073 source_replay_guarantee("rest"),
2074 ReplayGuarantee::NonDeterministic
2075 );
2076 assert_eq!(sink_guarantee("postgres"), SinkGuarantee::AtomicWatermark);
2077 assert_eq!(sink_guarantee("elasticsearch"), SinkGuarantee::KeyedUpsert);
2079 assert_eq!(sink_guarantee("jsonl"), SinkGuarantee::AtLeastOnce);
2080 }
2081
2082 #[test]
2083 fn sink_supported_write_modes_allowlist() {
2084 use faucet_core::WriteMode;
2085 assert!(sink_supported_write_modes("postgres").contains(&WriteMode::Upsert));
2086 assert!(sink_supported_write_modes("elasticsearch").contains(&WriteMode::Delete));
2087 assert!(sink_supported_write_modes("bigquery").contains(&WriteMode::Upsert));
2088 assert_eq!(sink_supported_write_modes("jsonl"), &[WriteMode::Append]);
2090 assert_eq!(sink_supported_write_modes("kafka"), &[WriteMode::Append]);
2091
2092 for k in [
2095 "postgres",
2096 "sqlite",
2097 "mysql",
2098 "mssql",
2099 "mongodb",
2100 "bigquery",
2101 "elasticsearch",
2102 ] {
2103 assert!(
2104 sink_supported_write_modes(k).contains(&WriteMode::Overwrite),
2105 "{k} should support overwrite"
2106 );
2107 assert!(sink_supports_overwrite(k), "{k} sink_supports_overwrite");
2108 }
2109 assert!(!sink_supports_overwrite("jsonl"));
2111 }
2112
2113 #[test]
2114 fn sink_supports_schema_evolution_allowlist() {
2115 assert!(sink_supports_schema_evolution("postgres"));
2116 assert!(sink_supports_schema_evolution("mysql"));
2117 assert!(sink_supports_schema_evolution("mssql"));
2118 assert!(sink_supports_schema_evolution("sqlite"));
2119 assert!(sink_supports_schema_evolution("bigquery"));
2120 assert!(sink_supports_schema_evolution("elasticsearch"));
2121 assert!(sink_supports_schema_evolution("iceberg"));
2122 assert!(!sink_supports_schema_evolution("jsonl"));
2123 assert!(!sink_supports_schema_evolution("kafka"));
2124 }
2125}