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