use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fmt::Display};
use utoipa::ToSchema;
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
pub enum IcebergIngestMode {
#[serde(rename = "snapshot")]
Snapshot,
#[serde(rename = "follow")]
Follow,
#[serde(rename = "snapshot_and_follow")]
SnapshotAndFollow,
}
impl Display for IcebergIngestMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IcebergIngestMode::Snapshot => f.write_str("snapshot"),
IcebergIngestMode::Follow => f.write_str("follow"),
IcebergIngestMode::SnapshotAndFollow => f.write_str("snapshot_and_follow"),
}
}
}
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
pub enum IcebergCatalogType {
#[serde(rename = "rest")]
Rest,
#[serde(rename = "glue")]
Glue,
#[serde(rename = "s3tables")]
S3Tables,
}
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema, Default)]
pub enum IcebergTransactionMode {
#[default]
#[serde(rename = "none")]
None,
#[serde(rename = "snapshot")]
Snapshot,
#[serde(rename = "catchup")]
Catchup,
#[serde(rename = "always")]
Always,
}
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
pub struct GlueCatalogConfig {
#[serde(rename = "glue.warehouse")]
pub warehouse: Option<String>,
#[serde(rename = "glue.endpoint")]
pub endpoint: Option<String>,
#[serde(rename = "glue.access-key-id")]
pub access_key_id: Option<String>,
#[serde(rename = "glue.secret-access-key")]
pub secret_access_key: Option<String>,
#[serde(rename = "glue.profile-name")]
pub profile_name: Option<String>,
#[serde(rename = "glue.region")]
pub region: Option<String>,
#[serde(rename = "glue.session-token")]
pub session_token: Option<String>,
#[serde(rename = "glue.id")]
pub id: Option<String>,
}
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
pub struct RestCatalogConfig {
#[serde(rename = "rest.uri")]
pub uri: Option<String>,
#[serde(rename = "rest.warehouse")]
pub warehouse: Option<String>,
#[serde(rename = "rest.oauth2-server-uri")]
pub oauth2_server_uri: Option<String>,
#[serde(rename = "rest.credential")]
pub credential: Option<String>,
#[serde(rename = "rest.token")]
pub token: Option<String>,
#[serde(rename = "rest.scope")]
pub scope: Option<String>,
#[serde(rename = "rest.prefix")]
pub prefix: Option<String>,
#[serde(default)]
#[serde(rename = "rest.headers")]
pub headers: Option<Vec<(String, String)>>,
#[serde(rename = "rest.audience")]
pub audience: Option<String>,
#[serde(rename = "rest.resource")]
pub resource: Option<String>,
}
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
pub struct S3TablesCatalogConfig {
#[serde(rename = "s3tables.table-bucket-arn")]
pub table_bucket_arn: Option<String>,
#[serde(rename = "s3tables.endpoint")]
pub endpoint: Option<String>,
#[serde(rename = "s3tables.access-key-id")]
pub access_key_id: Option<String>,
#[serde(rename = "s3tables.secret-access-key")]
pub secret_access_key: Option<String>,
#[serde(rename = "s3tables.session-token")]
pub session_token: Option<String>,
#[serde(rename = "s3tables.profile-name")]
pub profile_name: Option<String>,
#[serde(rename = "s3tables.region")]
pub region: Option<String>,
}
fn default_num_parsers() -> u32 {
4
}
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
pub struct IcebergReaderConfig {
pub mode: IcebergIngestMode,
#[serde(default)]
pub transaction_mode: IcebergTransactionMode,
pub timestamp_column: Option<String>,
pub snapshot_filter: Option<String>,
pub snapshot_id: Option<i64>,
pub datetime: Option<String>,
pub end_snapshot_id: Option<i64>,
pub metadata_location: Option<String>,
pub table_name: Option<String>,
pub catalog_type: Option<IcebergCatalogType>,
#[serde(default = "default_num_parsers")]
#[schema(minimum = 1)]
pub num_parsers: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_retries: Option<u32>,
#[serde(flatten)]
pub glue_catalog_config: GlueCatalogConfig,
#[serde(flatten)]
pub rest_catalog_config: RestCatalogConfig,
#[serde(flatten)]
pub s3tables_catalog_config: S3TablesCatalogConfig,
#[serde(flatten)]
pub fileio_config: HashMap<String, String>,
}
impl IcebergReaderConfig {
pub fn validate_catalog_config(&self) -> Result<(), String> {
self.validate_metadata_location()?;
self.validate_table_name()?;
self.validate_glue_catalog_config()?;
self.validate_rest_catalog_config()?;
self.validate_s3tables_catalog_config()?;
Ok(())
}
pub fn validate_glue_catalog_config(&self) -> Result<(), String> {
if self.catalog_type == Some(IcebergCatalogType::Glue) {
if self.glue_catalog_config.warehouse.is_none() {
return Err(r#"missing Iceberg warehouse location—set the 'glue.warehouse' property to the location of the Iceberg tables managed by the catalog (e.g., 's3://my-data-warehouse/tables/') when using "catalog_type" = "glue""#.to_string());
}
} else {
ensure_glue_property_not_set(&self.glue_catalog_config.warehouse, "warehouse")?;
ensure_glue_property_not_set(&self.glue_catalog_config.endpoint, "uri")?;
ensure_glue_property_not_set(&self.glue_catalog_config.access_key_id, "access-key-id")?;
ensure_glue_property_not_set(
&self.glue_catalog_config.secret_access_key,
"secret-access-key",
)?;
ensure_glue_property_not_set(&self.glue_catalog_config.profile_name, "profile-name")?;
ensure_glue_property_not_set(&self.glue_catalog_config.region, "region")?;
ensure_glue_property_not_set(&self.glue_catalog_config.session_token, "session-token")?;
ensure_glue_property_not_set(&self.glue_catalog_config.id, "id")?;
}
Ok(())
}
pub fn validate_rest_catalog_config(&self) -> Result<(), String> {
if self.catalog_type == Some(IcebergCatalogType::Rest) {
if self.rest_catalog_config.uri.is_none() {
return Err(r#"missing Iceberg Rest catalog URI—set the 'rest.uri' property when using "catalog_type" = "rest""#.to_string());
}
} else {
ensure_rest_property_not_set(&self.rest_catalog_config.uri, "uri")?;
ensure_rest_property_not_set(&self.rest_catalog_config.warehouse, "warehouse")?;
ensure_rest_property_not_set(
&self.rest_catalog_config.oauth2_server_uri,
"oauth2_server_uri",
)?;
ensure_rest_property_not_set(&self.rest_catalog_config.credential, "credential")?;
ensure_rest_property_not_set(&self.rest_catalog_config.token, "token")?;
ensure_rest_property_not_set(&self.rest_catalog_config.scope, "scope")?;
ensure_rest_property_not_set(&self.rest_catalog_config.prefix, "prefix")?;
ensure_rest_property_not_set(&self.rest_catalog_config.headers, "headers")?;
ensure_rest_property_not_set(&self.rest_catalog_config.audience, "audience")?;
ensure_rest_property_not_set(&self.rest_catalog_config.resource, "resource")?;
}
Ok(())
}
pub fn validate_s3tables_catalog_config(&self) -> Result<(), String> {
if self.catalog_type == Some(IcebergCatalogType::S3Tables) {
if self.s3tables_catalog_config.table_bucket_arn.is_none() {
return Err(r#"missing S3 table bucket ARN; set the 's3tables.table-bucket-arn' property to the ARN of the S3 table bucket (e.g., 'arn:aws:s3tables:us-east-2:123456789012:bucket/my-bucket') when using "catalog_type" = "s3tables""#.to_string());
}
} else {
ensure_s3tables_property_not_set(
&self.s3tables_catalog_config.table_bucket_arn,
"table-bucket-arn",
)?;
ensure_s3tables_property_not_set(&self.s3tables_catalog_config.endpoint, "endpoint")?;
ensure_s3tables_property_not_set(
&self.s3tables_catalog_config.access_key_id,
"access-key-id",
)?;
ensure_s3tables_property_not_set(
&self.s3tables_catalog_config.secret_access_key,
"secret-access-key",
)?;
ensure_s3tables_property_not_set(
&self.s3tables_catalog_config.session_token,
"session-token",
)?;
ensure_s3tables_property_not_set(
&self.s3tables_catalog_config.profile_name,
"profile-name",
)?;
ensure_s3tables_property_not_set(&self.s3tables_catalog_config.region, "region")?;
}
Ok(())
}
pub fn validate_table_name(&self) -> Result<(), String> {
if self.catalog_type.is_none() && self.table_name.is_some() {
Err("unexpected 'table_name' property: the 'table_name' property is valid only when an Iceberg catalog is configured using 'catalog_type'".to_string())
} else if self.catalog_type.is_some() && self.table_name.is_none() {
Err("missing 'table_name' property—'table_name' must be specified when Iceberg catalog is configured using 'catalog_type'".to_string())
} else {
Ok(())
}
}
pub fn validate_metadata_location(&self) -> Result<(), String> {
if self.catalog_type.is_none() && self.metadata_location.is_none() {
Err("missing metadata location: you must either specify an Iceberg catalog configuration by setting the 'catalog_type' property or provide a table metadata location directly via the 'metadata_location' property".to_string())
} else if self.catalog_type.is_some() && self.metadata_location.is_some() {
Err("unexpected 'metadata_location' property: the 'metadata_location' property is not supported when an Iceberg catalog is configured using 'catalog_type'".to_string())
} else {
Ok(())
}
}
}
fn ensure_glue_property_not_set<T>(property: &Option<T>, name: &str) -> Result<(), String> {
if property.is_some() {
Err(format!(
r#"unexpected 'glue.{name}' property—Glue catalog configuration properties are only valid when "catalog_type" = "glue""#
))
} else {
Ok(())
}
}
fn ensure_rest_property_not_set<T>(property: &Option<T>, name: &str) -> Result<(), String> {
if property.is_some() {
Err(format!(
r#"unexpected 'rest.{name}' property—Rest catalog configuration properties are only valid when "catalog_type" = "rest""#
))
} else {
Ok(())
}
}
fn ensure_s3tables_property_not_set<T>(property: &Option<T>, name: &str) -> Result<(), String> {
if property.is_some() {
Err(format!(
r#"unexpected 's3tables.{name}' property—S3 Tables catalog configuration properties are only valid when "catalog_type" = "s3tables""#
))
} else {
Ok(())
}
}
impl IcebergReaderConfig {
pub fn max_retries(&self) -> u32 {
self.max_retries.unwrap_or(u32::MAX)
}
pub fn snapshot(&self) -> bool {
matches!(
&self.mode,
IcebergIngestMode::Snapshot | IcebergIngestMode::SnapshotAndFollow
)
}
pub fn follow(&self) -> bool {
matches!(
&self.mode,
IcebergIngestMode::SnapshotAndFollow | IcebergIngestMode::Follow
)
}
}
#[cfg(test)]
mod test {
use super::*;
use serde_json::json;
fn config(value: serde_json::Value) -> IcebergReaderConfig {
serde_json::from_value(value).unwrap()
}
#[test]
fn s3tables_config_deserializes() {
let config = config(json!({
"mode": "snapshot",
"catalog_type": "s3tables",
"table_name": "namespace.table",
"s3tables.table-bucket-arn": "arn:aws:s3tables:us-east-2:123456789012:bucket/my-bucket",
"s3tables.region": "us-east-2",
"s3tables.access-key-id": "key",
"s3tables.secret-access-key": "secret",
"s3tables.session-token": "token",
"s3tables.profile-name": "profile",
"s3tables.endpoint": "http://localhost:4566",
}));
let s3tables = &config.s3tables_catalog_config;
assert_eq!(
s3tables.table_bucket_arn.as_deref(),
Some("arn:aws:s3tables:us-east-2:123456789012:bucket/my-bucket")
);
assert_eq!(s3tables.region.as_deref(), Some("us-east-2"));
assert_eq!(s3tables.access_key_id.as_deref(), Some("key"));
assert_eq!(s3tables.secret_access_key.as_deref(), Some("secret"));
assert_eq!(s3tables.session_token.as_deref(), Some("token"));
assert_eq!(s3tables.profile_name.as_deref(), Some("profile"));
assert_eq!(s3tables.endpoint.as_deref(), Some("http://localhost:4566"));
assert!(config.fileio_config.is_empty());
config.validate_catalog_config().unwrap();
}
#[test]
fn s3tables_requires_table_bucket_arn() {
let err = config(json!({
"mode": "snapshot",
"catalog_type": "s3tables",
"table_name": "namespace.table",
}))
.validate_catalog_config()
.unwrap_err();
assert!(err.contains("s3tables.table-bucket-arn"), "{err}");
}
#[test]
fn s3tables_props_rejected_for_other_catalog() {
let err = config(json!({
"mode": "snapshot",
"catalog_type": "glue",
"table_name": "namespace.table",
"glue.warehouse": "s3://warehouse/",
"s3tables.region": "us-east-2",
}))
.validate_catalog_config()
.unwrap_err();
assert!(err.contains("s3tables.region"), "{err}");
}
#[test]
fn s3tables_props_rejected_without_catalog() {
let err = config(json!({
"mode": "snapshot",
"metadata_location": "s3://warehouse/metadata.json",
"s3tables.table-bucket-arn": "arn:aws:s3tables:us-east-2:123456789012:bucket/my-bucket",
}))
.validate_catalog_config()
.unwrap_err();
assert!(err.contains("s3tables.table-bucket-arn"), "{err}");
}
#[test]
fn num_parsers_and_max_retries_defaults() {
let config: IcebergReaderConfig = serde_json::from_str(
r#"{"mode":"snapshot","metadata_location":"file:///tmp/t/metadata.json"}"#,
)
.unwrap();
assert_eq!(config.num_parsers, 4);
assert_eq!(config.max_retries, None);
assert_eq!(config.max_retries(), u32::MAX);
}
#[test]
fn num_parsers_and_max_retries_explicit() {
let config: IcebergReaderConfig = serde_json::from_str(
r#"{"mode":"snapshot","metadata_location":"file:///tmp/t/metadata.json","num_parsers":8,"max_retries":0}"#,
)
.unwrap();
assert_eq!(config.num_parsers, 8);
assert_eq!(config.max_retries, Some(0));
assert_eq!(config.max_retries(), 0);
}
#[test]
fn transaction_mode_defaults_to_none() {
let config: IcebergReaderConfig = serde_json::from_str(
r#"{"mode":"snapshot","metadata_location":"file:///tmp/t/metadata.json"}"#,
)
.unwrap();
assert_eq!(config.transaction_mode, IcebergTransactionMode::None);
}
#[test]
fn transaction_mode_snapshot_parses() {
let config: IcebergReaderConfig = serde_json::from_str(
r#"{"mode":"snapshot","metadata_location":"file:///tmp/t/metadata.json","transaction_mode":"snapshot"}"#,
)
.unwrap();
assert_eq!(config.transaction_mode, IcebergTransactionMode::Snapshot);
}
#[test]
fn reader_config_roundtrips() {
let config: IcebergReaderConfig = serde_json::from_str(
r#"{"mode":"snapshot","metadata_location":"file:///tmp/t/metadata.json","num_parsers":2}"#,
)
.unwrap();
let serialized = serde_json::to_string(&config).unwrap();
let reparsed: IcebergReaderConfig = serde_json::from_str(&serialized).unwrap();
assert_eq!(config, reparsed);
assert_eq!(reparsed.num_parsers, 2);
}
}