1use std::collections::HashMap;
14use std::fmt;
15
16use faucet_core::{FaucetError, WriteMode};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
29pub(crate) enum WarehouseScheme {
30 Local,
32 S3(&'static str),
35 Gcs,
37 Unsupported(String),
39}
40
41pub(crate) fn warehouse_scheme(warehouse: &str) -> WarehouseScheme {
46 let scheme = match warehouse.trim().split_once("://") {
47 Some((s, _)) => s.to_ascii_lowercase(),
48 None => return WarehouseScheme::Local,
49 };
50 match scheme.as_str() {
51 "file" => WarehouseScheme::Local,
52 "s3" => WarehouseScheme::S3("s3"),
53 "s3a" => WarehouseScheme::S3("s3a"),
54 "gs" => WarehouseScheme::Gcs,
55 other => WarehouseScheme::Unsupported(other.to_string()),
56 }
57}
58
59#[derive(Clone, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "snake_case")]
68pub struct CatalogInner {
69 #[serde(default)]
75 pub uri: Option<String>,
76
77 #[serde(default)]
79 pub warehouse: Option<String>,
80
81 #[serde(default)]
85 pub credential: Option<String>,
86
87 #[serde(default)]
90 pub properties: HashMap<String, String>,
91}
92
93#[derive(Clone, Serialize, Deserialize, JsonSchema)]
107#[serde(tag = "type", rename_all = "snake_case")]
108pub enum CatalogConfig {
109 Rest(CatalogInner),
112
113 Glue(CatalogInner),
116
117 Sql(CatalogInner),
119
120 Hms(CatalogInner),
123}
124
125impl CatalogConfig {
126 fn inner(&self) -> &CatalogInner {
127 match self {
128 CatalogConfig::Rest(i)
129 | CatalogConfig::Glue(i)
130 | CatalogConfig::Sql(i)
131 | CatalogConfig::Hms(i) => i,
132 }
133 }
134}
135
136impl fmt::Debug for CatalogConfig {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 let type_name = match self {
140 CatalogConfig::Rest(_) => "rest",
141 CatalogConfig::Glue(_) => "glue",
142 CatalogConfig::Sql(_) => "sql",
143 CatalogConfig::Hms(_) => "hms",
144 };
145 let inner = self.inner();
146 let uri_display = inner.uri.as_deref().map(|_| "***").unwrap_or("<none>");
148 let cred_display = inner
149 .credential
150 .as_deref()
151 .map(|_| "***")
152 .unwrap_or("<none>");
153 f.debug_struct("CatalogConfig")
154 .field("type", &type_name)
155 .field("uri", &uri_display)
156 .field("warehouse", &inner.warehouse)
157 .field("credential", &cred_display)
158 .field(
159 "properties_keys",
160 &inner.properties.keys().collect::<Vec<_>>(),
161 )
162 .finish()
163 }
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
175pub struct PartitionField {
176 pub source: String,
178
179 pub transform: String,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
188pub struct ParquetOpts {
189 #[serde(default = "default_compression")]
192 pub compression: String,
193}
194
195fn default_compression() -> String {
196 "snappy".to_string()
197}
198
199impl Default for ParquetOpts {
200 fn default() -> Self {
201 ParquetOpts {
202 compression: default_compression(),
203 }
204 }
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
231pub struct IcebergSinkConfig {
232 pub catalog: CatalogConfig,
234
235 pub namespace: Vec<String>,
238
239 pub table: String,
241
242 #[serde(default = "default_create_if_missing")]
246 pub create_if_missing: bool,
247
248 #[serde(default)]
251 pub partition_spec: Vec<PartitionField>,
252
253 #[serde(default)]
258 pub write_mode: WriteMode,
259
260 #[serde(default = "default_target_file_size_mb")]
263 pub target_file_size_mb: u64,
264
265 #[serde(default)]
267 pub parquet: ParquetOpts,
268
269 #[serde(default)]
271 pub snapshot_properties: HashMap<String, String>,
272
273 #[serde(default = "default_batch_size")]
276 pub batch_size: usize,
277
278 #[serde(default)]
301 pub cleanup_orphans_on_failure: bool,
302}
303
304fn default_create_if_missing() -> bool {
305 true
306}
307
308fn default_target_file_size_mb() -> u64 {
309 256
310}
311
312fn default_batch_size() -> usize {
313 10_000
314}
315
316const KNOWN_TRANSFORMS: &[&str] = &["identity", "year", "month", "day", "hour", "void"];
320
321fn is_valid_transform(t: &str) -> bool {
327 if KNOWN_TRANSFORMS.contains(&t) {
328 return true;
329 }
330 for prefix in &["bucket[", "truncate["] {
332 if let Some(rest) = t.strip_prefix(prefix)
333 && let Some(n_str) = rest.strip_suffix(']')
334 {
335 return n_str.parse::<u64>().map(|n| n > 0).unwrap_or(false);
336 }
337 }
338 false
339}
340
341impl IcebergSinkConfig {
342 pub fn validate(&self) -> Result<(), FaucetError> {
350 if self.namespace.is_empty() {
352 return Err(FaucetError::Config(
353 "iceberg: `namespace` must contain at least one segment".to_string(),
354 ));
355 }
356 for seg in &self.namespace {
357 if seg.is_empty() {
358 return Err(FaucetError::Config(
359 "iceberg: `namespace` segments must not be empty".to_string(),
360 ));
361 }
362 }
363
364 if self.table.is_empty() {
366 return Err(FaucetError::Config(
367 "iceberg: `table` must not be empty".to_string(),
368 ));
369 }
370
371 for (i, pf) in self.partition_spec.iter().enumerate() {
373 if pf.source.is_empty() {
374 return Err(FaucetError::Config(format!(
375 "iceberg: partition_spec[{i}].source must not be empty"
376 )));
377 }
378 if !is_valid_transform(&pf.transform) {
379 return Err(FaucetError::Config(format!(
380 "iceberg: partition_spec[{i}].transform {:?} is not a recognised Iceberg \
381 transform; expected one of: {} or parameterized bucket[N] / truncate[N]",
382 pf.transform,
383 KNOWN_TRANSFORMS.join(", ")
384 )));
385 }
386 }
387
388 let (uri_required, kind) = match &self.catalog {
393 CatalogConfig::Rest(_) => (true, "rest"),
394 CatalogConfig::Sql(_) => (true, "sql"),
395 CatalogConfig::Hms(_) => (true, "hms"),
396 CatalogConfig::Glue(_) => (false, "glue"),
397 };
398 if uri_required
399 && self
400 .catalog
401 .inner()
402 .uri
403 .as_deref()
404 .map(str::trim)
405 .unwrap_or("")
406 .is_empty()
407 {
408 return Err(FaucetError::Config(format!(
409 "iceberg: catalog '{kind}' requires a non-empty `uri`"
410 )));
411 }
412
413 if !matches!(self.catalog, CatalogConfig::Rest(_)) {
417 let warehouse = self.catalog.inner().warehouse.as_deref().unwrap_or("");
418 if let WarehouseScheme::Unsupported(s) = warehouse_scheme(warehouse) {
419 return Err(FaucetError::Config(format!(
420 "iceberg: warehouse scheme '{s}://' is not supported for the \
421 '{kind}' catalog; use file://, s3://, s3a://, or gs:// (or the \
422 REST catalog for other object stores)"
423 )));
424 }
425 }
426
427 if self.target_file_size_mb == 0 {
430 return Err(FaucetError::Config(
431 "iceberg: `target_file_size_mb` must be > 0".to_string(),
432 ));
433 }
434
435 faucet_core::validate_batch_size(self.batch_size)?;
437
438 Ok(())
439 }
440}
441
442#[cfg(test)]
445mod tests {
446 use super::*;
447
448 fn minimal_config_json() -> serde_json::Value {
449 serde_json::json!({
450 "catalog": { "type": "rest", "uri": "http://localhost:8181" },
451 "namespace": ["analytics"],
452 "table": "events"
453 })
454 }
455
456 fn parse(v: serde_json::Value) -> IcebergSinkConfig {
457 serde_json::from_value(v).expect("parse failed")
458 }
459
460 #[test]
463 fn defaults_are_applied() {
464 let cfg = parse(minimal_config_json());
465 assert!(cfg.create_if_missing);
466 assert_eq!(cfg.target_file_size_mb, 256);
467 assert_eq!(cfg.parquet.compression, "snappy");
468 assert_eq!(cfg.write_mode, WriteMode::Append);
469 assert_eq!(cfg.batch_size, 10_000);
470 assert!(cfg.partition_spec.is_empty());
471 assert!(cfg.snapshot_properties.is_empty());
472 }
473
474 #[test]
477 fn catalog_rest_round_trip() {
478 let v = serde_json::json!({
479 "type": "rest",
480 "uri": "https://catalog.example.com",
481 "warehouse": "s3://lake/wh",
482 "credential": "my-token",
483 "properties": { "region": "us-east-1" }
484 });
485 let cat: CatalogConfig = serde_json::from_value(v).unwrap();
486 assert!(matches!(cat, CatalogConfig::Rest(_)));
487 let inner = cat.inner();
488 assert_eq!(inner.uri.as_deref(), Some("https://catalog.example.com"));
489 assert_eq!(inner.credential.as_deref(), Some("my-token"));
490 assert_eq!(
491 inner.properties.get("region").map(String::as_str),
492 Some("us-east-1")
493 );
494
495 let json = serde_json::to_value(&cat).unwrap();
497 assert_eq!(json["type"], "rest");
498 let _cat2: CatalogConfig = serde_json::from_value(json).unwrap();
499 }
500
501 #[test]
502 fn catalog_glue_round_trip() {
503 let v = serde_json::json!({ "type": "glue", "warehouse": "s3://lake/wh" });
504 let cat: CatalogConfig = serde_json::from_value(v).unwrap();
505 assert!(matches!(cat, CatalogConfig::Glue(_)));
506 }
507
508 #[test]
509 fn catalog_sql_round_trip() {
510 let v = serde_json::json!({
511 "type": "sql",
512 "uri": "postgres://localhost/meta",
513 "warehouse": "s3://lake/wh"
514 });
515 let cat: CatalogConfig = serde_json::from_value(v).unwrap();
516 assert!(matches!(cat, CatalogConfig::Sql(_)));
517 }
518
519 #[test]
520 fn catalog_hms_round_trip() {
521 let v = serde_json::json!({ "type": "hms", "uri": "thrift://hms:9083" });
522 let cat: CatalogConfig = serde_json::from_value(v).unwrap();
523 assert!(matches!(cat, CatalogConfig::Hms(_)));
524 }
525
526 #[test]
529 fn write_mode_defaults_to_append() {
530 let cfg = parse(minimal_config_json());
531 assert_eq!(cfg.write_mode, WriteMode::Append);
532 }
533
534 #[test]
535 fn write_mode_append_explicit() {
536 let mut v = minimal_config_json();
537 v["write_mode"] = serde_json::json!("append");
538 let cfg = parse(v);
539 assert_eq!(cfg.write_mode, WriteMode::Append);
540 }
541
542 #[test]
543 fn write_mode_overwrite_is_rejected() {
544 let mut v = minimal_config_json();
545 v["write_mode"] = serde_json::json!("overwrite");
546 let err = serde_json::from_value::<IcebergSinkConfig>(v);
547 assert!(err.is_err(), "expected error for write_mode=overwrite");
548 let msg = err.unwrap_err().to_string();
549 assert!(
550 msg.contains("unknown variant") || msg.contains("overwrite"),
551 "error message should mention unknown variant or overwrite: {msg}"
552 );
553 }
554
555 #[test]
558 fn valid_transforms_accepted() {
559 let transforms = [
560 "identity",
561 "year",
562 "month",
563 "day",
564 "hour",
565 "void",
566 "bucket[5]",
567 "bucket[16]",
568 "truncate[8]",
569 ];
570 for t in transforms {
571 assert!(is_valid_transform(t), "{t} should be valid");
572 }
573 }
574
575 #[test]
576 fn invalid_transform_rejected_by_validate() {
577 let mut v = minimal_config_json();
578 v["partition_spec"] = serde_json::json!([{ "source": "col", "transform": "frobnicate" }]);
579 let cfg = parse(v);
580 let err = cfg.validate().unwrap_err();
581 assert!(matches!(err, FaucetError::Config(_)));
582 let msg = err.to_string();
583 assert!(
584 msg.contains("frobnicate"),
585 "error should mention the bad transform: {msg}"
586 );
587 }
588
589 #[test]
590 fn bucket_zero_is_rejected() {
591 assert!(!is_valid_transform("bucket[0]"));
592 }
593
594 #[test]
595 fn valid_partition_spec_passes_validate() {
596 let mut v = minimal_config_json();
597 v["partition_spec"] = serde_json::json!([
598 { "source": "event_date", "transform": "day" },
599 { "source": "tenant_id", "transform": "identity" },
600 { "source": "bucket_col", "transform": "bucket[16]" }
601 ]);
602 let cfg = parse(v);
603 cfg.validate().expect("should pass");
604 }
605
606 #[test]
609 fn empty_namespace_is_rejected() {
610 let mut v = minimal_config_json();
611 v["namespace"] = serde_json::json!([]);
612 let cfg = parse(v);
613 let err = cfg.validate().unwrap_err();
614 assert!(matches!(err, FaucetError::Config(_)));
615 let msg = err.to_string();
616 assert!(msg.contains("namespace"), "should mention namespace: {msg}");
617 }
618
619 #[test]
620 fn namespace_with_empty_segment_is_rejected() {
621 let mut v = minimal_config_json();
622 v["namespace"] = serde_json::json!(["analytics", "", "events"]);
623 let cfg = parse(v);
624 let err = cfg.validate().unwrap_err();
625 assert!(matches!(err, FaucetError::Config(_)));
626 }
627
628 #[test]
629 fn empty_table_is_rejected() {
630 let mut v = minimal_config_json();
631 v["table"] = serde_json::json!("");
632 let cfg = parse(v);
633 let err = cfg.validate().unwrap_err();
634 assert!(matches!(err, FaucetError::Config(_)));
635 let msg = err.to_string();
636 assert!(msg.contains("table"), "should mention table: {msg}");
637 }
638
639 #[test]
642 fn rest_catalog_without_uri_is_rejected() {
643 let cfg = parse(serde_json::json!({
644 "catalog": { "type": "rest" },
645 "namespace": ["analytics"],
646 "table": "events"
647 }));
648 let err = cfg.validate().unwrap_err();
649 assert!(matches!(err, FaucetError::Config(_)));
650 assert!(err.to_string().contains("uri"), "should mention uri: {err}");
651 }
652
653 #[test]
654 fn sql_and_hms_without_uri_are_rejected() {
655 for ty in ["sql", "hms"] {
656 let cfg = parse(serde_json::json!({
657 "catalog": { "type": ty },
658 "namespace": ["analytics"],
659 "table": "events"
660 }));
661 assert!(cfg.validate().is_err(), "{ty} without uri should fail");
662 }
663 }
664
665 #[test]
666 fn rest_catalog_with_whitespace_uri_is_rejected() {
667 let cfg = parse(serde_json::json!({
668 "catalog": { "type": "rest", "uri": " " },
669 "namespace": ["analytics"],
670 "table": "events"
671 }));
672 assert!(cfg.validate().is_err(), "whitespace-only uri should fail");
673 }
674
675 #[test]
676 fn zero_target_file_size_is_rejected() {
677 let mut v = minimal_config_json();
678 v["target_file_size_mb"] = serde_json::json!(0);
679 assert!(parse(v).validate().is_err());
680 }
681
682 #[test]
683 fn glue_catalog_without_uri_is_allowed() {
684 let cfg = parse(serde_json::json!({
686 "catalog": { "type": "glue", "warehouse": "s3://lake/wh" },
687 "namespace": ["analytics"],
688 "table": "events"
689 }));
690 assert!(cfg.validate().is_ok());
691 }
692
693 #[test]
696 fn sql_catalog_rejects_unsupported_warehouse_scheme() {
697 let cfg = parse(serde_json::json!({
698 "catalog": { "type": "sql", "uri": "sqlite::memory:", "warehouse": "oss://bucket/wh" },
699 "namespace": ["analytics"],
700 "table": "events"
701 }));
702 let err = cfg.validate().unwrap_err();
703 assert!(matches!(err, FaucetError::Config(_)));
704 let msg = err.to_string();
705 assert!(msg.contains("oss"), "should name the bad scheme: {msg}");
706 }
707
708 #[test]
709 fn sql_catalog_accepts_cloud_and_local_warehouses() {
710 for w in [
711 "s3://bucket/wh",
712 "s3a://bucket/wh",
713 "gs://bucket/wh",
714 "file:///tmp/wh",
715 "/tmp/wh",
716 ] {
717 let cfg = parse(serde_json::json!({
718 "catalog": { "type": "sql", "uri": "sqlite::memory:", "warehouse": w },
719 "namespace": ["analytics"],
720 "table": "events"
721 }));
722 cfg.validate()
723 .unwrap_or_else(|e| panic!("{w} should validate: {e}"));
724 }
725 }
726
727 #[test]
728 fn rest_catalog_allows_any_warehouse_scheme() {
729 let cfg = parse(serde_json::json!({
730 "catalog": { "type": "rest", "uri": "http://localhost:8181", "warehouse": "oss://bucket/wh" },
731 "namespace": ["analytics"],
732 "table": "events"
733 }));
734 cfg.validate()
735 .expect("REST should accept any warehouse scheme");
736 }
737
738 #[test]
741 fn iceberg_config_parses_upsert_against_core_enum() {
742 let cfg: IcebergSinkConfig = serde_json::from_value(serde_json::json!({
746 "catalog": { "type": "rest", "uri": "http://localhost:8181" },
747 "namespace": ["analytics"],
748 "table": "events",
749 "write_mode": "upsert"
750 }))
751 .expect("upsert parses against the core enum");
752 assert_eq!(cfg.write_mode, faucet_core::WriteMode::Upsert);
753 }
754
755 #[test]
758 fn batch_size_above_max_is_rejected() {
759 let mut v = minimal_config_json();
760 v["batch_size"] = serde_json::json!(1_000_001usize);
761 let cfg = parse(v);
762 let err = cfg.validate().unwrap_err();
763 assert!(matches!(err, FaucetError::Config(_)));
764 }
765
766 #[test]
767 fn batch_size_at_max_is_accepted() {
768 let mut v = minimal_config_json();
769 v["batch_size"] = serde_json::json!(1_000_000usize);
770 let cfg = parse(v);
771 cfg.validate().expect("max batch_size should be accepted");
772 }
773
774 #[test]
775 fn batch_size_zero_is_accepted() {
776 let mut v = minimal_config_json();
777 v["batch_size"] = serde_json::json!(0usize);
778 let cfg = parse(v);
779 cfg.validate()
780 .expect("batch_size=0 sentinel should be accepted");
781 }
782
783 #[test]
786 fn debug_redacts_credential() {
787 let v = serde_json::json!({
788 "type": "rest",
789 "uri": "https://catalog.example.com/api",
790 "credential": "super-secret-token"
791 });
792 let cat: CatalogConfig = serde_json::from_value(v).unwrap();
793 let debug_str = format!("{cat:?}");
794 assert!(
795 !debug_str.contains("super-secret-token"),
796 "credential must be redacted: {debug_str}"
797 );
798 assert!(
799 debug_str.contains("***"),
800 "should show *** placeholder: {debug_str}"
801 );
802 }
803
804 #[test]
805 fn debug_redacts_uri() {
806 let v = serde_json::json!({
807 "type": "rest",
808 "uri": "https://user:pass@catalog.example.com"
809 });
810 let cat: CatalogConfig = serde_json::from_value(v).unwrap();
811 let debug_str = format!("{cat:?}");
812 assert!(
814 !debug_str.contains("user:pass"),
815 "uri userinfo must be redacted: {debug_str}"
816 );
817 }
818
819 #[test]
822 fn warehouse_scheme_local_variants() {
823 use super::{WarehouseScheme, warehouse_scheme};
824 for w in [
825 "",
826 "/tmp/warehouse",
827 "./wh",
828 "relative/dir",
829 "file:///tmp/wh",
830 ] {
831 assert!(
832 matches!(warehouse_scheme(w), WarehouseScheme::Local),
833 "{w:?} should be Local"
834 );
835 }
836 }
837
838 #[test]
839 fn warehouse_scheme_s3_preserves_scheme() {
840 use super::{WarehouseScheme, warehouse_scheme};
841 assert!(matches!(
842 warehouse_scheme("s3://bucket/wh"),
843 WarehouseScheme::S3("s3")
844 ));
845 assert!(matches!(
846 warehouse_scheme("s3a://bucket/wh"),
847 WarehouseScheme::S3("s3a")
848 ));
849 assert!(matches!(
850 warehouse_scheme("S3://bucket/wh"),
851 WarehouseScheme::S3("s3")
852 ));
853 }
854
855 #[test]
856 fn warehouse_scheme_gcs() {
857 use super::{WarehouseScheme, warehouse_scheme};
858 assert!(matches!(
859 warehouse_scheme("gs://bucket/wh"),
860 WarehouseScheme::Gcs
861 ));
862 }
863
864 #[test]
865 fn warehouse_scheme_unsupported() {
866 use super::{WarehouseScheme, warehouse_scheme};
867 match warehouse_scheme("oss://bucket/wh") {
868 WarehouseScheme::Unsupported(s) => assert_eq!(s, "oss"),
869 other => panic!("expected Unsupported, got {other:?}"),
870 }
871 assert!(matches!(
872 warehouse_scheme("abfss://x/y"),
873 WarehouseScheme::Unsupported(_)
874 ));
875 }
876}